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