Bots that attack, units that look like units

The planner worked one unit at a time, picking each one's best destination
independently. That is why nothing was ever taken: a defended province is
attacked by a single unit, bounces, and is attacked again next year for ever.
It now works the other way round -- list what is worth having, work out how
many units it takes, and commit that many or none. A province you cannot take
is not worth one unit.

Three things came out of that. A single unit still walks at an *empty*
province, since the worst case is a bounce and it might be free, but never at
a garrison. Garrisoning now needs two neighbours rather than one, because one
unit cannot dislodge another -- with one counting as a threat, almost every
centre on a crowded board was threatened, almost every unit stood still, and
nothing happened for two centuries. And an attack is priced at two units
unless the defender has two friends near enough to prop it up; assuming one
was enough meant almost every attack was priced at three, which nobody can
afford.

Asking for help now works from what a power wanted and could not have. It
used to read the moves already planned, which was exactly backwards once the
planner learned not to attack what it cannot take: the target is no longer in
the plan, so the bot stopped asking precisely when it needed to.

The game is now called at the end of 1912. This game has no clock of its own
and seven cautious powers will hold a standoff for ever -- a run to 2198 had
the board still alive and nobody near eighteen. Eighteen wins outright and
otherwise the survivors draw, which is a proper ending here and the commonest
way real games finish.

Units are a soldier and a ship rather than two wedges. A helmet and square
shoulders read as troops at any size; a triangle above a curved hull is a
boat before anybody has decided to look. Both in the power's colour with one
flat highlight, and a shadow so they sit on the ground instead of floating
over a border.
This commit is contained in:
2026-09-09 08:40:51 -07:00
parent 75104e2614
commit 4c1ebbab4a
6 changed files with 304 additions and 103 deletions
+24 -2
View File
@@ -96,11 +96,33 @@ header select {
.label.wet { fill: #63808f; font-weight: 700; }
.unit path {
.unit {
pointer-events: none;
}
.unit path,
.unit circle,
.unit ellipse {
stroke: #2b2318;
stroke-width: 3;
stroke-linejoin: round;
pointer-events: none;
stroke-linecap: round;
}
/* Sitting the unit on the ground rather than floating it over the border. */
.unit .shadow {
fill: rgba(43, 35, 24, 0.28);
stroke: none;
}
.unit .mast { stroke-width: 3.5; }
.unit .brim { stroke-width: 3.5; fill: none; }
/* One flat highlight, no gradients: the same cel rule the whole site uses. */
.unit .lit {
fill: none;
stroke: rgba(255, 253, 245, 0.75);
stroke-width: 2.6;
}
/* --- the orders ------------------------------------------------------ */
+46 -5
View File
@@ -144,13 +144,11 @@ export function Board({
const p = CENTRES[unit.at] ?? CENTRES[base(unit.at)] ?? CENTRES[at]!
return (
<g className="unit" key={at} transform={`translate(${p.x} ${p.y})`}>
<ellipse className="shadow" cx="0" cy="13" rx="17" ry="4" />
{unit.type === 'army' ? (
<path d="M-15 9 L-15 -3 L0 -13 L15 -3 L15 9 Z" fill={COLOURS[unit.power]} />
<Soldier colour={COLOURS[unit.power]} />
) : (
<path
d="M-17 7 L17 7 L9 -1 L3 -1 L3 -13 L-4 -1 L-17 -1 Z"
fill={COLOURS[unit.power]}
/>
<Ship colour={COLOURS[unit.power]} />
)}
</g>
)
@@ -159,6 +157,49 @@ export function Board({
)
}
/**
* An army: a soldier from the shoulders up, in a helmet.
*
* A person reads as troops at any size, which two stacked rectangles never
* did -- the first pass drew a blockhouse and a wedge and at map scale they
* were two similar smudges in the same colour. The silhouette has to do the
* work, so the helmet is wider than the head and the shoulders are square.
*/
function Soldier({ colour }: { colour: string }) {
return (
<g>
<path className="body" d="M-13 13 L-13 3 C-13 -3 -7 -6 0 -6 C7 -6 13 -3 13 3 L13 13 Z" fill={colour} />
<circle className="head" cx="0" cy="-9" r="6.5" fill={colour} />
{/* The brim is what makes it a helmet rather than a head. */}
<path className="helmet" d="M-10 -11 C-10 -18 10 -18 10 -11 Z" fill={colour} />
<path className="brim" d="M-11.5 -10.5 L11.5 -10.5" />
<path className="lit" d="M-8 12 L-8 3 C-8 -1 -5 -3 -2 -3.5" />
</g>
)
}
/**
* A fleet: a hull, a mast and a sail.
*
* The sail is the tell. A hull on its own is a wedge and a wedge is whatever
* you already thought it was; a triangle above a curve is a boat before
* anybody has decided to look.
*/
function Ship({ colour }: { colour: string }) {
return (
<g>
<path className="sail" d="M1.5 -17 L14 1 L1.5 1 Z" fill={colour} />
<path className="mast" d="M0 2 L0 -17" />
<path
className="hull"
d="M-16 2 L16 2 C15 9 11 13 6 13 L-6 13 C-11 13 -15 9 -16 2 Z"
fill={colour}
/>
<path className="lit" d="M-12 4 C-11 8 -8 10 -4 10.5" />
</g>
)
}
export const POWER_NAMES: Record<Power, string> = {
austria: 'Austria',
england: 'England',
+155 -82
View File
@@ -36,48 +36,11 @@ export interface Choice {
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<string, Order>()
const claimed = new Set<string>()
const spent = new Set<string>()
/*
* Garrison first. A unit already standing on something worth having claims
@@ -85,42 +48,77 @@ export function chooseOrders(pos: Position, mind: Mind, turn: number): Choice {
*
* 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.
* threatened and one spare unit, it marched the Munich garrison to Berlin
* -- defending one centre by abandoning another of the same value.
*/
for (const [at] of mine) {
if (!threatened(pos, mind.power, at)) continue
orders.set(at, { type: 'hold', at, power: mind.power })
claimed.add(at)
spent.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
/*
* Then take objectives, not moves.
*
* This is the difference between a bot that plays and one that shuffles.
* Picking each unit's best destination independently means a defended
* province is attacked by one unit, bounces, and is attacked again next
* year for ever -- which is exactly what seven of these did to each other
* until 2149 without anybody taking a single centre off anybody.
*
* So work the other way round: list what is worth having, work out how many
* units it takes, and commit that many or none at all. A province you
* cannot take is not worth one unit, and the unit is worth more somewhere
* it can actually arrive.
*/
const targets = Object.keys(PROVINCES)
.map((id) => ({ id, worth: desire(pos, mind.power, id) }))
.filter((t) => t.worth > 0)
.sort((a, b) => b.worth - a.worth || a.id.localeCompare(b.id))
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})`)
for (const target of targets) {
if (pos.board.get(target.id)?.power === mind.power) continue
// 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`)
const able = mine
.filter(([at]) => !spent.has(at))
.filter(([, u]) => canReach(u, target.id))
.map(([at, u]) => ({ at, to: aim(u, target.id) }))
if (able.length === 0) continue
const needed = strengthNeeded(pos, mind.power, target.id)
/*
* Not enough for the job. Walking at an empty province anyway is still
* worth doing -- the worst that happens is a bounce, and the province
* might be free -- but throwing one unit at a garrison is a unit thrown
* away, and that one waits for help instead.
*/
if (able.length < needed) {
if (pos.board.get(base(target.id))) continue
const lone = able[0]!
orders.set(lone.at, { type: 'move', at: lone.at, to: lone.to, power: mind.power })
spent.add(lone.at)
reasoning.push(`${lone.at} -> ${target.id} (worth ${target.worth}, and empty)`)
continue
}
const [lead, ...rest] = able
orders.set(lead!.at, { type: 'move', at: lead!.at, to: lead!.to, power: mind.power })
spent.add(lead!.at)
reasoning.push(`${lead!.at} -> ${target.id} (worth ${target.worth}, needs ${needed})`)
for (const helper of rest.slice(0, needed - 1)) {
orders.set(helper.at, {
type: 'support',
at: helper.at,
from: lead!.at,
to: lead!.to,
power: mind.power,
})
spent.add(helper.at)
reasoning.push(`${helper.at} supports it`)
}
}
// Anything still idle stands where it is.
@@ -134,9 +132,7 @@ export function chooseOrders(pos: Position, mind: Mind, turn: number): Choice {
* 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.
* for all twenty-two units including everybody else's.
*/
const plan = validate(pos.board, [...orders.values()])
const ours = [...plan.orders.entries()]
@@ -146,6 +142,55 @@ export function chooseOrders(pos: Position, mind: Mind, turn: number): Choice {
return { orders: ours, broke, reasoning }
}
/**
* How many units it takes to arrive somewhere.
*
* One for an empty province nobody else wants. Two if somebody is standing
* there, since a unit must be beaten rather than matched. One more again if
* a friend of theirs is close enough to prop them up, or if a rival could
* reach the same empty province and bounce us out of it.
*
* It is an estimate and it is meant to be. Being exactly right would mean
* knowing everybody's orders, which is the one thing this game never lets
* anybody know.
*/
function strengthNeeded(pos: Position, power: Power, target: string): number {
const holding = pos.board.get(base(target))
const rivals = [...pos.board.entries()].filter(
([at, u]) => u.power !== power && base(at) !== base(target) && canReach(u, target),
)
if (!holding) return rivals.length > 0 ? 2 : 1
/*
* Two to beat a unit, and a third only when they have two friends close
* enough to prop it up. Assuming one nearby friend means a third unit
* needed was the difference between a bot that presses an advantage and one
* that never quite attacks: a neighbour almost always has *a* unit
* somewhere near, so almost every attack was priced at three, and three
* spare units next to the same province is a luxury nobody has.
*/
const friendsOfTheirs = rivals.filter(([, u]) => u.power === holding.power).length
return friendsOfTheirs >= 2 ? 3 : 2
}
/** Can this unit reach that province, by any coast of it? */
function canReach(unit: Unit, province: string): boolean {
if (canStep(unit, base(province))) return true
const coasts = PROVINCES[base(province)]?.coasts
return coasts !== undefined && coasts.some((c) => canStep(unit, `${base(province)}/${c}`))
}
/** The destination as the order must name it, coast and all. */
function aim(unit: Unit, province: string): string {
const coasts = PROVINCES[base(province)]?.coasts
if (unit.type === 'army' || !coasts) return base(province)
const open = coasts
.map((c) => `${base(province)}/${c}`)
.filter((key) => canStep(unit, key))
return open.length === 1 ? open[0]! : base(province)
}
/**
* Go through the promises and decide which ones to keep.
*
@@ -370,20 +415,23 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
const id = (to: Power, what: string) => `${mind.power}-${to}-${turn}-${what}`
// --- help me take this -------------------------------------------------
for (const order of plan.orders) {
if (order.type !== 'move') continue
const target = base(order.to)
if (desire(pos, mind.power, order.to) < 100) continue
// Already covered by one of my own units, so there is nothing to ask.
if (plan.orders.some((o) => o.type === 'support' && base(o.to) === target)) continue
const holding = pos.board.get(target)
/*
* Ask about what you wanted and could not have.
*
* This used to work from the moves already planned, which had it exactly
* backwards once the planner learned not to attack what it cannot take:
* a province worth wanting and beyond reach alone is no longer in the plan
* at all, so the bot stopped asking for help precisely when it needed
* help. What is worth an approach is the target that was skipped.
*/
for (const target of wantedAndUnaffordable(pos, mind)) {
const holding = pos.board.get(base(target.id))
if (!holding || holding.power === mind.power) continue
if (plan.orders.some((o) => o.type === 'support' && base(o.to) === target.id)) continue
const helpers = [...pos.board.entries()]
.filter(([, u]) => u.power !== mind.power && u.power !== holding.power)
.filter(([, u]) => canStep(u, order.to))
.filter(([, u]) => canReach(u, target.id))
.sort((a, b) => believe(b[1].power) - believe(a[1].power))
const helper = helpers[0]
@@ -391,7 +439,7 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
out.push({
proposal: {
id: id(helper[1].power, `sup-${target}`),
id: id(helper[1].power, `sup-${target.id}`),
from: mind.power,
to: helper[1].power,
turn,
@@ -399,11 +447,11 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
kind: 'support',
mover: mind.power,
helper: helper[1].power,
from: order.at,
to: order.to,
from: target.from,
to: target.to,
},
},
says: `Support my ${base(order.at)} into ${target} and it is mine this turn.`,
says: `Support my ${base(target.from)} into ${target.id} and it is mine this turn.`,
})
}
@@ -452,6 +500,31 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
return out.slice(0, MOUTHFUL)
}
/**
* Provinces this power wants and has not got the units for on its own: the
* whole reason to go and talk to somebody.
*/
function wantedAndUnaffordable(
pos: Position,
mind: Mind,
): { id: string; from: string; to: string; worth: number }[] {
const mine = [...pos.board.entries()].filter(([, u]) => u.power === mind.power)
const out: { id: string; from: string; to: string; worth: number }[] = []
for (const id of Object.keys(PROVINCES)) {
const worth = desire(pos, mind.power, id)
if (worth < 100) continue
if (pos.board.get(id)?.power === mind.power) continue
const able = mine.filter(([, u]) => canReach(u, id))
if (able.length === 0) continue
if (able.length >= strengthNeeded(pos, mind.power, id)) continue
out.push({ id, from: able[0]![0], to: aim(able[0]![1], id), worth })
}
return out.sort((a, b) => b.worth - a.worth)
}
/** Powers whose units are within reach of anything of ours. */
function neighbours(pos: Position, us: Power): Power[] {
const near = new Set<Power>()
+28 -7
View File
@@ -77,14 +77,35 @@ export function desire(pos: Position, power: Power, province: string): number {
}
/**
* 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.
* A centre of ours somebody could actually take: the one thing worth standing
* still for.
*
* Two neighbours, not one. A single unit cannot dislodge another -- it needs
* a supporter -- so garrisoning against one is a unit thrown away. That
* sounds like a detail and it decided whole games: with one neighbour
* counting as a threat, almost every centre on a crowded board is threatened,
* almost every unit garrisons, and seven powers stare at each other for two
* centuries without a province changing hands.
*/
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)
export function threatened(pos: Position, power: Power, province: string): boolean {
const id = base(province)
if (!PROVINCES[id]!.sc || pos.own.get(id) !== power) return false
return neighbours(pos, id, power) >= 2
}
/** How many of somebody else's units are standing next door. */
function neighbours(pos: Position, province: string, power: Power): number {
let n = 0
for (const [, unit] of pos.board) {
if (unit.power === power) continue
if (canStep(unit, province)) n++
else {
const coasts = PROVINCES[province]?.coasts
if (coasts?.some((c) => canStep(unit, `${province}/${c}`))) n++
}
}
return n
}
/** Is somebody else's unit standing next door to this centre of ours? */
function pressured(pos: Position, province: string, power: Power): boolean {
+36
View File
@@ -40,6 +40,24 @@ import {
* finish a game rather than shuffle for thirty years.
*/
/**
* The year the game stops.
*
* Diplomacy has no clock of its own: it ends when somebody takes eighteen
* centres or when the players agree to stop, and agreeing to stop is a
* conversation seven computer powers are not going to have. Left alone they
* reach a standoff and hold it -- a game played out with nobody intervening
* ran to 2198 with the board still changing hands and nobody near a solo.
*
* That is not a bug to be tuned away. It is what a table of equally cautious
* players does, and it is why real games are called. So the game is called:
* eighteen centres wins outright, and if nobody has them by the end of 1912
* the survivors draw. Twelve years is a long evening and a real tournament
* length, and a draw is a proper ending here rather than a failure to finish
* -- it is the commonest way this game actually ends.
*/
export const LAST_YEAR = 1912
export type Season = 'spring' | 'autumn'
export type Phase = 'orders' | 'retreats' | 'builds' | 'over'
@@ -57,6 +75,8 @@ export interface Game {
/** Set while retreats are outstanding. */
outcome: Outcome | null
winner: Power | null
/** Everybody still standing when the game was called. */
drawn: Power[]
/** Powers with no centres left. They stay on the list, at nothing. */
out: Power[]
}
@@ -81,6 +101,7 @@ export function newGame(): Game {
log: ['Spring 1901. Nobody has said anything yet.'],
outcome: null,
winner: null,
drawn: [],
out: [],
}
}
@@ -239,6 +260,21 @@ function afterRetreats(g: Game): Game {
if (winner) return { ...g, own, out, winner, phase: 'over', log }
if (g.year >= LAST_YEAR) {
const drawn = POWERS.filter((p) => !out.includes(p))
return {
...g,
own,
out,
drawn,
phase: 'over',
log: [
...log,
`The end of ${g.year}. Nobody has eighteen, so it is a draw between ${drawn.join(', ')}.`,
],
}
}
const owing = POWERS.some((p) => !out.includes(p) && adjustmentFor(own, g.board, p) !== 0)
return {
...g,