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:
2026-09-09 07:30:59 -07:00
parent 16d047ea81
commit efbecf6425
3 changed files with 295 additions and 2 deletions
+161
View File
@@ -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()
}