diff --git a/README.md b/README.md
index 1e8a1c4..3fa7903 100644
--- a/README.md
+++ b/README.md
@@ -213,15 +213,23 @@ 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.
+**The game is called at the end of 1912.** Diplomacy has no clock of its own:
+it ends on eighteen centres, or when the players agree to stop -- and
+agreeing to stop is a conversation seven computer powers are never going to
+have. Left alone they reach a standoff and hold it, which is what a table of
+equally cautious players does and is why real games are called.
+
+So: 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 **the draw is a proper ending here rather than a failure to
+finish** -- it is the commonest way this game actually ends.
+
+Worth saying plainly: seven bots left alone draw. They take centres off each
+other now, which they could not do at all before, but none of them breaks
+away to a solo. Beating six of them is the game.
## Still to build
-- 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
+- bots strong enough to solo against each other, not only to draw
- the music, the battle sound, the four endings
diff --git a/src/App.css b/src/App.css
index 70d5d7a..0a61bd1 100644
--- a/src/App.css
+++ b/src/App.css
@@ -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 ------------------------------------------------------ */
diff --git a/src/components/Board.tsx b/src/components/Board.tsx
index d73d0a7..fe24364 100644
--- a/src/components/Board.tsx
+++ b/src/components/Board.tsx
@@ -144,13 +144,11 @@ export function Board({
const p = CENTRES[unit.at] ?? CENTRES[base(unit.at)] ?? CENTRES[at]!
return (
+
{unit.type === 'army' ? (
-
+
) : (
-
+
)}
)
@@ -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 (
+
+
+
+ {/* The brim is what makes it a helmet rather than a head. */}
+
+
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+ )
+}
+
export const POWER_NAMES: Record = {
austria: 'Austria',
england: 'England',
diff --git a/src/game/bot.ts b/src/game/bot.ts
index ef29372..2b4dfb6 100644
--- a/src/game/bot.ts
+++ b/src/game/bot.ts
@@ -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()
- const claimed = new Set()
+ const spent = new Set()
/*
* 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()
diff --git a/src/game/evaluate.ts b/src/game/evaluate.ts
index 5ca0aa2..07ee7d6 100644
--- a/src/game/evaluate.ts
+++ b/src/game/evaluate.ts
@@ -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 {
diff --git a/src/game/game.ts b/src/game/game.ts
index b0fb000..58c67f0 100644
--- a/src/game/game.ts
+++ b/src/game/game.ts
@@ -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,