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:
@@ -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
|
the end of it. Approaches to the human are handed back rather than answered
|
||||||
for them.
|
for them.
|
||||||
|
|
||||||
**What it does not do yet is finish.** Played out to 2149 the board is alive
|
**The game is called at the end of 1912.** Diplomacy has no clock of its own:
|
||||||
and nobody has soloed: the powers take the neutrals in the first few years
|
it ends on eighteen centres, or when the players agree to stop -- and
|
||||||
and then hold each other off indefinitely. Taking a defended centre needs two
|
agreeing to stop is a conversation seven computer powers are never going to
|
||||||
units on it, and the bots do not reliably arrange that even with deals being
|
have. Left alone they reach a standoff and hold it, which is what a table of
|
||||||
struck every turn. That is the next piece of work and it is a fact about the
|
equally cautious players does and is why real games are called.
|
||||||
bots, not the loop, so the test says what the loop guarantees and no more.
|
|
||||||
|
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
|
## 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
|
- 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
|
- the music, the battle sound, the four endings
|
||||||
|
|||||||
+24
-2
@@ -96,11 +96,33 @@ header select {
|
|||||||
|
|
||||||
.label.wet { fill: #63808f; font-weight: 700; }
|
.label.wet { fill: #63808f; font-weight: 700; }
|
||||||
|
|
||||||
.unit path {
|
.unit {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit path,
|
||||||
|
.unit circle,
|
||||||
|
.unit ellipse {
|
||||||
stroke: #2b2318;
|
stroke: #2b2318;
|
||||||
stroke-width: 3;
|
stroke-width: 3;
|
||||||
stroke-linejoin: round;
|
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 ------------------------------------------------------ */
|
/* --- the orders ------------------------------------------------------ */
|
||||||
|
|||||||
@@ -144,13 +144,11 @@ export function Board({
|
|||||||
const p = CENTRES[unit.at] ?? CENTRES[base(unit.at)] ?? CENTRES[at]!
|
const p = CENTRES[unit.at] ?? CENTRES[base(unit.at)] ?? CENTRES[at]!
|
||||||
return (
|
return (
|
||||||
<g className="unit" key={at} transform={`translate(${p.x} ${p.y})`}>
|
<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' ? (
|
{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
|
<Ship colour={COLOURS[unit.power]} />
|
||||||
d="M-17 7 L17 7 L9 -1 L3 -1 L3 -13 L-4 -1 L-17 -1 Z"
|
|
||||||
fill={COLOURS[unit.power]}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</g>
|
</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> = {
|
export const POWER_NAMES: Record<Power, string> = {
|
||||||
austria: 'Austria',
|
austria: 'Austria',
|
||||||
england: 'England',
|
england: 'England',
|
||||||
|
|||||||
+155
-82
@@ -36,48 +36,11 @@ export interface Choice {
|
|||||||
reasoning: string[]
|
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 {
|
export function chooseOrders(pos: Position, mind: Mind, turn: number): Choice {
|
||||||
const mine = [...pos.board.entries()].filter(([, u]) => u.power === mind.power)
|
const mine = [...pos.board.entries()].filter(([, u]) => u.power === mind.power)
|
||||||
const reasoning: string[] = []
|
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 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
|
* 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
|
* Without this the bot does something that looks deranged and is a direct
|
||||||
* consequence of only ever scoring *moves*: with Munich and Berlin both
|
* consequence of only ever scoring *moves*: with Munich and Berlin both
|
||||||
* threatened and only one spare unit, it marched the Munich garrison to
|
* threatened and one spare unit, it marched the Munich garrison to Berlin
|
||||||
* Berlin -- defending one centre by abandoning another of exactly the same
|
* -- defending one centre by abandoning another of the same value.
|
||||||
* value. A province you are standing in is already yours; the question is
|
|
||||||
* only whether to leave.
|
|
||||||
*/
|
*/
|
||||||
for (const [at] of mine) {
|
for (const [at] of mine) {
|
||||||
if (!threatened(pos, mind.power, at)) continue
|
if (!threatened(pos, mind.power, at)) continue
|
||||||
orders.set(at, { type: 'hold', at, power: mind.power })
|
orders.set(at, { type: 'hold', at, power: mind.power })
|
||||||
claimed.add(at)
|
spent.add(at)
|
||||||
reasoning.push(`${at} stays where it is`)
|
reasoning.push(`${at} stays where it is`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const want of wants) {
|
/*
|
||||||
if (orders.has(want.at) || claimed.has(base(want.to))) continue
|
* Then take objectives, not moves.
|
||||||
if (want.worth <= 0) continue
|
*
|
||||||
|
* 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 })
|
for (const target of targets) {
|
||||||
claimed.add(base(want.to))
|
if (pos.board.get(target.id)?.power === mind.power) continue
|
||||||
reasoning.push(`${want.at} -> ${base(want.to)} (worth ${want.worth})`)
|
|
||||||
|
|
||||||
// Somebody else of mine who could also reach it should push rather than
|
const able = mine
|
||||||
// wander off. Only when it is worth more than a centre: a spare unit
|
.filter(([at]) => !spent.has(at))
|
||||||
// shoving at an empty province is a unit not taking a different one.
|
.filter(([, u]) => canReach(u, target.id))
|
||||||
if (want.worth < 100) continue
|
.map(([at, u]) => ({ at, to: aim(u, target.id) }))
|
||||||
const second = mine.find(
|
if (able.length === 0) continue
|
||||||
([at, u]) => !orders.has(at) && at !== want.at && options(u).some((o) => base(o) === base(want.to)),
|
|
||||||
)
|
const needed = strengthNeeded(pos, mind.power, target.id)
|
||||||
if (!second) continue
|
|
||||||
orders.set(second[0], {
|
/*
|
||||||
type: 'support',
|
* Not enough for the job. Walking at an empty province anyway is still
|
||||||
at: second[0],
|
* worth doing -- the worst that happens is a bounce, and the province
|
||||||
from: want.at,
|
* might be free -- but throwing one unit at a garrison is a unit thrown
|
||||||
to: want.to,
|
* away, and that one waits for help instead.
|
||||||
power: mind.power,
|
*/
|
||||||
})
|
if (able.length < needed) {
|
||||||
reasoning.push(`${second[0]} supports it`)
|
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.
|
// 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
|
* 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
|
* 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
|
* 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
|
* for all twenty-two units including everybody else's.
|
||||||
* until seven powers were asked at once and England submitted orders for
|
|
||||||
* the whole board.
|
|
||||||
*/
|
*/
|
||||||
const plan = validate(pos.board, [...orders.values()])
|
const plan = validate(pos.board, [...orders.values()])
|
||||||
const ours = [...plan.orders.entries()]
|
const ours = [...plan.orders.entries()]
|
||||||
@@ -146,6 +142,55 @@ export function chooseOrders(pos: Position, mind: Mind, turn: number): Choice {
|
|||||||
return { orders: ours, broke, reasoning }
|
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.
|
* 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}`
|
const id = (to: Power, what: string) => `${mind.power}-${to}-${turn}-${what}`
|
||||||
|
|
||||||
// --- help me take this -------------------------------------------------
|
/*
|
||||||
for (const order of plan.orders) {
|
* Ask about what you wanted and could not have.
|
||||||
if (order.type !== 'move') continue
|
*
|
||||||
const target = base(order.to)
|
* This used to work from the moves already planned, which had it exactly
|
||||||
if (desire(pos, mind.power, order.to) < 100) continue
|
* backwards once the planner learned not to attack what it cannot take:
|
||||||
// Already covered by one of my own units, so there is nothing to ask.
|
* a province worth wanting and beyond reach alone is no longer in the plan
|
||||||
if (plan.orders.some((o) => o.type === 'support' && base(o.to) === target)) continue
|
* 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.
|
||||||
const holding = pos.board.get(target)
|
*/
|
||||||
|
for (const target of wantedAndUnaffordable(pos, mind)) {
|
||||||
|
const holding = pos.board.get(base(target.id))
|
||||||
if (!holding || holding.power === mind.power) continue
|
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()]
|
const helpers = [...pos.board.entries()]
|
||||||
.filter(([, u]) => u.power !== mind.power && u.power !== holding.power)
|
.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))
|
.sort((a, b) => believe(b[1].power) - believe(a[1].power))
|
||||||
|
|
||||||
const helper = helpers[0]
|
const helper = helpers[0]
|
||||||
@@ -391,7 +439,7 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
|
|||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
proposal: {
|
proposal: {
|
||||||
id: id(helper[1].power, `sup-${target}`),
|
id: id(helper[1].power, `sup-${target.id}`),
|
||||||
from: mind.power,
|
from: mind.power,
|
||||||
to: helper[1].power,
|
to: helper[1].power,
|
||||||
turn,
|
turn,
|
||||||
@@ -399,11 +447,11 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
|
|||||||
kind: 'support',
|
kind: 'support',
|
||||||
mover: mind.power,
|
mover: mind.power,
|
||||||
helper: helper[1].power,
|
helper: helper[1].power,
|
||||||
from: order.at,
|
from: target.from,
|
||||||
to: order.to,
|
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)
|
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. */
|
/** Powers whose units are within reach of anything of ours. */
|
||||||
function neighbours(pos: Position, us: Power): Power[] {
|
function neighbours(pos: Position, us: Power): Power[] {
|
||||||
const near = new Set<Power>()
|
const near = new Set<Power>()
|
||||||
|
|||||||
+28
-7
@@ -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
|
* A centre of ours somebody could actually take: the one thing worth standing
|
||||||
* standing still for. Not a neutral centre we happen to be sitting on -- that
|
* still for.
|
||||||
* is worth taking, not worth freezing a unit over in the spring.
|
*
|
||||||
|
* 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 =>
|
export function threatened(pos: Position, power: Power, province: string): boolean {
|
||||||
PROVINCES[base(province)]!.sc &&
|
const id = base(province)
|
||||||
pos.own.get(base(province)) === power &&
|
if (!PROVINCES[id]!.sc || pos.own.get(id) !== power) return false
|
||||||
pressured(pos, base(province), power)
|
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? */
|
/** Is somebody else's unit standing next door to this centre of ours? */
|
||||||
function pressured(pos: Position, province: string, power: Power): boolean {
|
function pressured(pos: Position, province: string, power: Power): boolean {
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ import {
|
|||||||
* finish a game rather than shuffle for thirty years.
|
* 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 Season = 'spring' | 'autumn'
|
||||||
export type Phase = 'orders' | 'retreats' | 'builds' | 'over'
|
export type Phase = 'orders' | 'retreats' | 'builds' | 'over'
|
||||||
|
|
||||||
@@ -57,6 +75,8 @@ export interface Game {
|
|||||||
/** Set while retreats are outstanding. */
|
/** Set while retreats are outstanding. */
|
||||||
outcome: Outcome | null
|
outcome: Outcome | null
|
||||||
winner: Power | 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. */
|
/** Powers with no centres left. They stay on the list, at nothing. */
|
||||||
out: Power[]
|
out: Power[]
|
||||||
}
|
}
|
||||||
@@ -81,6 +101,7 @@ export function newGame(): Game {
|
|||||||
log: ['Spring 1901. Nobody has said anything yet.'],
|
log: ['Spring 1901. Nobody has said anything yet.'],
|
||||||
outcome: null,
|
outcome: null,
|
||||||
winner: null,
|
winner: null,
|
||||||
|
drawn: [],
|
||||||
out: [],
|
out: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,6 +260,21 @@ function afterRetreats(g: Game): Game {
|
|||||||
|
|
||||||
if (winner) return { ...g, own, out, winner, phase: 'over', log }
|
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)
|
const owing = POWERS.some((p) => !out.includes(p) && adjustmentFor(own, g.board, p) !== 0)
|
||||||
return {
|
return {
|
||||||
...g,
|
...g,
|
||||||
|
|||||||
Reference in New Issue
Block a user