Bots that open the conversation
A bot that only answers is not negotiating, it is a form to fill in. propose() works from the plan the power has already made: what it wants this turn, where that will not work alone, and who could make it work. Help taking something defended first, then a quiet border over ground neither of them owns, then peace when a neighbour is over your centres and your ambitions are elsewhere. Three a turn at most, or six powers talking at once is a mailstorm. Each overture carries what the power says, so it reaches a human as a message rather than as a form. A power will offer a quiet border to somebody it is attacking somewhere else, and that is on purpose. Marching on French Belgium while suggesting both sides leave Burgundy alone is not a lie and not incoherent -- a promise about Burgundy is a promise about Burgundy, and it will be kept to the letter by somebody robbing you at the same moment. A test asserted the opposite and the test was wrong: refusing that offer would be playing a politer game than this one. Asking the power you are attacking to help you, or offering it peace while your orders say otherwise, are different things and are both refused. Nothing here promises any of it will be kept. Whether the power still wants the deal when orders are due is settled in settleUp, and the answer is sometimes no.
This commit is contained in:
@@ -141,7 +141,27 @@ 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 exactly
|
||||
backwards. An ally who has already betrayed you is worth nothing to protect.
|
||||
|
||||
Bots open negotiations rather than only answering them. `propose()` works
|
||||
from the plan the power has already made: it decides what it wants to do this
|
||||
turn, notices where that will not work alone, and goes looking for somebody
|
||||
who could make it work. Help taking something defended, first; then a quiet
|
||||
border over ground neither of them owns; then peace outright, when a
|
||||
neighbour is over your centres and your ambitions are elsewhere. Three
|
||||
approaches a turn at most, because six powers all talking at once is a
|
||||
mailstorm rather than a negotiation.
|
||||
|
||||
Each overture carries what the power actually says, so it can be put in front
|
||||
of a human as a message rather than as a form.
|
||||
|
||||
**A power will offer a quiet border to somebody it is attacking somewhere
|
||||
else**, and that is deliberate. Germany marching on French Belgium while
|
||||
suggesting both sides leave Burgundy alone is not a lie and not incoherent: a
|
||||
promise about Burgundy is a promise about Burgundy, and it will be kept to
|
||||
the letter by somebody who is robbing you at the same moment. A bot that
|
||||
refused to make that offer would be playing a politer game than this one. It
|
||||
will not, however, ask the power it is attacking to *help*, or offer it peace
|
||||
while the orders say otherwise.
|
||||
|
||||
## Still to build
|
||||
|
||||
- proposals: bots opening a negotiation rather than only answering one
|
||||
- the map, the orders, the music, the battle sound, the four endings
|
||||
|
||||
+113
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { chooseOrders, consider, type Mind } from './bot'
|
||||
import { chooseOrders, consider, propose, type Mind } from './bot'
|
||||
import { desire, standing, type Position } from './evaluate'
|
||||
import { boardFrom, type Unit } from './orders'
|
||||
import type { Power } from './map'
|
||||
@@ -246,3 +246,115 @@ describe('a bot being asked', () => {
|
||||
expect(consider(pos, mind('germany'), offer, 4).reply).toBe('accept')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a bot doing the asking', () => {
|
||||
it('asks for help taking something it cannot take alone', () => {
|
||||
// Germany wants Belgium; France is sitting in it; England has a fleet
|
||||
// that could push. Germany has nobody of its own to spare.
|
||||
const pos = at(
|
||||
[A('germany', 'ruh'), A('france', 'bel'), F('england', 'nth')],
|
||||
[['bel', 'france']],
|
||||
)
|
||||
const overtures = propose(pos, mind('germany'), 1)
|
||||
const ask = overtures.find((o) => o.proposal.deal.kind === 'support')
|
||||
|
||||
expect(ask).toBeDefined()
|
||||
expect(ask!.proposal.to).toBe('england')
|
||||
expect(ask!.says).toContain('bel')
|
||||
})
|
||||
|
||||
it('never asks the power it is attacking to help it', () => {
|
||||
const pos = at([A('germany', 'ruh'), A('france', 'bel')], [['bel', 'france']])
|
||||
const overtures = propose(pos, mind('germany'), 1)
|
||||
expect(
|
||||
overtures.some((o) => o.proposal.deal.kind === 'support' && o.proposal.to === 'france'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never offers peace to somebody it is in the middle of attacking', () => {
|
||||
const pos = at([A('germany', 'ruh'), A('france', 'bel'), A('france', 'bur')], [['bel', 'france']])
|
||||
const overtures = propose(pos, mind('germany'), 1)
|
||||
expect(
|
||||
overtures.some((o) => o.proposal.deal.kind === 'peace' && o.proposal.to === 'france'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('will offer a quiet border to somebody it is attacking somewhere else', () => {
|
||||
/*
|
||||
* Germany marches on French Belgium and in the same breath suggests
|
||||
* leaving Burgundy alone. That is not a lie and it is not incoherent: a
|
||||
* promise about Burgundy is a promise about Burgundy. It is the sharpest
|
||||
* thing in this game -- an offer that will be kept to the letter by
|
||||
* somebody who is robbing you at the same moment -- and a bot that
|
||||
* refused to make it would be playing a politer game than this one.
|
||||
*/
|
||||
const pos = at([A('germany', 'ruh'), A('france', 'bel')], [['bel', 'france']])
|
||||
const overtures = propose(pos, mind('germany'), 1)
|
||||
const dmz = overtures.find((o) => o.proposal.deal.kind === 'dmz')
|
||||
expect(dmz?.proposal.to).toBe('france')
|
||||
expect(dmz!.proposal.deal.kind === 'dmz' && dmz!.proposal.deal.province).not.toBe('bel')
|
||||
})
|
||||
|
||||
it('asks whoever it believes most', () => {
|
||||
const pos = at(
|
||||
[A('germany', 'ruh'), A('france', 'bel'), A('england', 'hol'), A('italy', 'bur')],
|
||||
[['bel', 'france']],
|
||||
)
|
||||
// England has lied repeatedly; Italy has not.
|
||||
let ledger = emptyLedger()
|
||||
const board = boardFrom([A('germany', 'ruh'), A('england', 'hol')])
|
||||
for (const turn of [1, 2, 3]) {
|
||||
ledger = remember(
|
||||
ledger,
|
||||
judge(
|
||||
deal('germany', 'england', { kind: 'dmz', province: 'bel' }, turn),
|
||||
new Map([
|
||||
['germany' as Power, [{ type: 'hold' as const, at: 'ruh' }]],
|
||||
['england' as Power, [{ type: 'move' as const, at: 'hol', to: 'bel' }]],
|
||||
]),
|
||||
board,
|
||||
),
|
||||
)
|
||||
}
|
||||
const ask = propose(pos, mind('germany', [], ledger), 4).find(
|
||||
(o) => o.proposal.deal.kind === 'support',
|
||||
)
|
||||
expect(ask!.proposal.to).toBe('italy')
|
||||
})
|
||||
|
||||
it('offers a quiet border over ground neither of them owns', () => {
|
||||
const pos = at([A('germany', 'mun'), A('austria', 'tyr')])
|
||||
const overtures = propose(pos, mind('germany'), 1)
|
||||
const dmz = overtures.find((o) => o.proposal.deal.kind === 'dmz')
|
||||
expect(dmz).toBeDefined()
|
||||
expect(dmz!.proposal.to).toBe('austria')
|
||||
expect(dmz!.says).toContain('empty')
|
||||
})
|
||||
|
||||
it('does not offer to leave alone a province it is walking into', () => {
|
||||
const pos = at([A('germany', 'ruh'), A('france', 'bur')])
|
||||
const overtures = propose(pos, mind('germany'), 1)
|
||||
const plan = chooseOrders(pos, mind('germany'), 1)
|
||||
const taking = plan.orders
|
||||
.filter((o) => o.type === 'move')
|
||||
.map((o) => (o as { to: string }).to.split('/')[0]!)
|
||||
for (const o of overtures) {
|
||||
if (o.proposal.deal.kind !== 'dmz') continue
|
||||
expect(taking).not.toContain(o.proposal.deal.province)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not open its mouth more than three times a turn', () => {
|
||||
const pos = at([
|
||||
A('germany', 'mun'),
|
||||
A('germany', 'ber'),
|
||||
A('germany', 'kie'),
|
||||
A('france', 'bur'),
|
||||
A('austria', 'tyr'),
|
||||
A('russia', 'sil'),
|
||||
A('russia', 'pru'),
|
||||
A('england', 'hol'),
|
||||
])
|
||||
expect(propose(pos, mind('germany'), 1).length).toBeLessThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
+161
@@ -317,3 +317,164 @@ function threatFrom(pos: Position, them: Power, us: Power): number {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- speaking up
|
||||
|
||||
export interface Overture {
|
||||
proposal: Proposal
|
||||
/** What the power says when it asks. Shown to whoever is being asked. */
|
||||
says: string
|
||||
}
|
||||
|
||||
/** At most this many approaches a turn, so six powers are not a mailstorm. */
|
||||
const MOUTHFUL = 3
|
||||
|
||||
/**
|
||||
* What this power goes and asks for, unprompted.
|
||||
*
|
||||
* A bot that only ever answers is not negotiating, it is a form to fill in.
|
||||
* These come out of the plan it has already made: it works out what it wants
|
||||
* to do this turn, notices where that is not going to work on its own, and
|
||||
* goes looking for somebody who could make it work.
|
||||
*
|
||||
* In order of how much a deal is worth having:
|
||||
*
|
||||
* - **help taking something defended.** The commonest ask in the game and
|
||||
* the one that decides most provinces.
|
||||
* - **a quiet border**, where a province sits between us that neither of us
|
||||
* can hold and both of us would rather not garrison.
|
||||
* - **peace outright**, when a neighbour is over my centres and my own
|
||||
* ambitions are somewhere else entirely.
|
||||
*
|
||||
* Nothing here promises the power will keep any of it. It is asking because
|
||||
* it wants something now; whether it still wants it when the orders are due
|
||||
* is settled separately, in `settleUp`, and the answer is sometimes no.
|
||||
*/
|
||||
export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
|
||||
const out: Overture[] = []
|
||||
const plan = chooseOrders(pos, { ...mind, agreements: [] }, turn)
|
||||
const believe = (them: Power) => trust(mind.ledger, mind.power, them, turn)
|
||||
|
||||
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)
|
||||
if (!holding || holding.power === mind.power) continue
|
||||
|
||||
const helpers = [...pos.board.entries()]
|
||||
.filter(([, u]) => u.power !== mind.power && u.power !== holding.power)
|
||||
.filter(([, u]) => canStep(u, order.to))
|
||||
.sort((a, b) => believe(b[1].power) - believe(a[1].power))
|
||||
|
||||
const helper = helpers[0]
|
||||
if (!helper) continue
|
||||
|
||||
out.push({
|
||||
proposal: {
|
||||
id: id(helper[1].power, `sup-${target}`),
|
||||
from: mind.power,
|
||||
to: helper[1].power,
|
||||
turn,
|
||||
deal: {
|
||||
kind: 'support',
|
||||
mover: mind.power,
|
||||
helper: helper[1].power,
|
||||
from: order.at,
|
||||
to: order.to,
|
||||
},
|
||||
},
|
||||
says: `Support my ${base(order.at)} into ${target} and it is mine this turn.`,
|
||||
})
|
||||
}
|
||||
|
||||
// --- let us both stay out of it ---------------------------------------
|
||||
for (const them of neighbours(pos, mind.power)) {
|
||||
if (out.some((o) => o.proposal.to === them)) continue
|
||||
const between = borderland(pos, mind.power, them).find(
|
||||
(p) => !plan.orders.some((o) => o.type === 'move' && base(o.to) === p),
|
||||
)
|
||||
if (!between) continue
|
||||
|
||||
out.push({
|
||||
proposal: {
|
||||
id: id(them, `dmz-${between}`),
|
||||
from: mind.power,
|
||||
to: them,
|
||||
turn,
|
||||
deal: { kind: 'dmz', province: between },
|
||||
},
|
||||
says: `Neither of us needs ${between}. Leave it empty and we both look elsewhere.`,
|
||||
})
|
||||
}
|
||||
|
||||
// --- peace, then ------------------------------------------------------
|
||||
for (const them of neighbours(pos, mind.power)) {
|
||||
if (out.some((o) => o.proposal.to === them)) continue
|
||||
if (threatFrom(pos, them, mind.power) < 2) continue
|
||||
// Not while I am the one moving on them.
|
||||
const attacking = plan.orders.some(
|
||||
(o) => o.type === 'move' && pos.board.get(base(o.to))?.power === them,
|
||||
)
|
||||
if (attacking) continue
|
||||
|
||||
out.push({
|
||||
proposal: {
|
||||
id: id(them, 'peace'),
|
||||
from: mind.power,
|
||||
to: them,
|
||||
turn,
|
||||
deal: { kind: 'peace' },
|
||||
},
|
||||
says: `You are on my border in force and I have business elsewhere. Peace this turn?`,
|
||||
})
|
||||
}
|
||||
|
||||
return out.slice(0, MOUTHFUL)
|
||||
}
|
||||
|
||||
/** Powers whose units are within reach of anything of ours. */
|
||||
function neighbours(pos: Position, us: Power): Power[] {
|
||||
const near = new Set<Power>()
|
||||
for (const [, unit] of pos.board) {
|
||||
if (unit.power === us) continue
|
||||
for (const [at, mine] of pos.board) {
|
||||
if (mine.power !== us) continue
|
||||
if (canStep(unit, at)) near.add(unit.power)
|
||||
}
|
||||
for (const [id, p] of Object.entries(PROVINCES)) {
|
||||
if (p.sc && pos.own.get(id) === us && canStep(unit, id)) near.add(unit.power)
|
||||
}
|
||||
}
|
||||
return [...near]
|
||||
}
|
||||
|
||||
/**
|
||||
* Provinces both of us can reach and neither of us owns: the ground a border
|
||||
* war starts over, and the ground worth agreeing to leave alone.
|
||||
*/
|
||||
function borderland(pos: Position, us: Power, them: Power): string[] {
|
||||
const reach = (who: Power) => {
|
||||
const out = new Set<string>()
|
||||
for (const [, unit] of pos.board) {
|
||||
if (unit.power !== who) continue
|
||||
for (const [id, p] of Object.entries(PROVINCES)) {
|
||||
if (canStep(unit, id)) out.add(id)
|
||||
else if (p.coasts?.some((c) => canStep(unit, `${id}/${c}`))) out.add(id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
const ours = reach(us)
|
||||
const theirs = reach(them)
|
||||
return [...ours]
|
||||
.filter((p) => theirs.has(p))
|
||||
.filter((p) => pos.own.get(p) !== us && pos.own.get(p) !== them)
|
||||
.sort()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user