The press

Three things a power can offer, because these are the three that change a
turn: support for a named move on a named turn, a demilitarised province, and
peace. All concrete, all about a particular turn, all checkable by
arithmetic against the orders actually submitted. A game where the machine
decides by feel whether you kept your word is a game where being betrayed
feels arbitrary rather than infuriating.

Deals are judged on the orders given, never on how they turned out. A support
that was ordered and cut was still given; a move that bounced was still
attempted. Blaming a power for a bounce would make every alliance a lottery
and every honest ally look like a liar.

Contradictory promises are the mechanic rather than something to prevent.
Promising a province to one power and supporting another into it is a double
deal, and conflicts() exists so that a power can be shown it has promised two
things it cannot both do -- at the moment of agreeing, deliberately.

Peace covers the centres a power owns as well as the provinces it occupies.
Walking into an undefended centre and calling it peace because nobody was
home is the first thing anybody tries. A test asserted the wrong thing here
and the comment above the code was promising more than the code did; the code
now does what it said.

Trust is directional and fades. A stranger starts above halfway, because a
game where every stranger is a proven liar never gets started, and an old
betrayal loses weight without leaving the ledger, so a broken alliance can
re-form out of need.
This commit is contained in:
2026-09-09 06:46:30 -07:00
parent 38f69e9aa6
commit 9c09750d1e
2 changed files with 478 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest'
import { boardFrom, type Order, type Unit } from './orders'
import type { Power } from './map'
import {
conflicts,
emptyLedger,
judge,
look,
remember,
trust,
type Agreement,
type Deal,
} from './press'
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
const mv = (at: string, to: string): Order => ({ type: 'move', at, to })
const sup = (at: string, from: string, to: string): Order => ({ type: 'support', at, from, to })
const hold = (at: string): Order => ({ type: 'hold', at })
const deal = (from: Power, to: Power, d: Deal, turn = 1): Agreement => ({
id: `${from}-${to}-${turn}`,
from,
to,
turn,
deal: d,
})
const board = boardFrom([A('austria', 'vie'), A('russia', 'war'), A('italy', 'ven')])
const orders = (o: Partial<Record<Power, Order[]>>): Map<Power, Order[]> =>
new Map(Object.entries(o) as [Power, Order[]][])
describe('a promise of support', () => {
const d = deal('austria', 'russia', {
kind: 'support',
mover: 'austria',
helper: 'russia',
from: 'vie',
to: 'gal',
})
it('is kept by both when both do as they said', () => {
const v = judge(d, orders({ austria: [mv('vie', 'gal')], russia: [sup('war', 'vie', 'gal')] }), board)
expect(v.every((x) => x.kept)).toBe(true)
})
it('names the one who did not, and only that one', () => {
const v = judge(d, orders({ austria: [mv('vie', 'gal')], russia: [hold('war')] }), board)
const broke = v.filter((x) => !x.kept)
expect(broke).toHaveLength(1)
expect(broke[0]!.power).toBe('russia')
expect(broke[0]!.why).toContain('did not order the support')
})
it('binds the asker too, who has to actually make the move', () => {
const v = judge(d, orders({ austria: [hold('vie')], russia: [sup('war', 'vie', 'gal')] }), board)
expect(v.find((x) => x.power === 'austria')!.kept).toBe(false)
expect(v.find((x) => x.power === 'russia')!.kept).toBe(true)
})
it('is judged on the order given, not on how it turned out', () => {
/*
* The support was ordered and would have been cut, or the move bounced.
* Neither is a lie. Blaming a power for a bounce would make every
* alliance a lottery and every honest ally look like a liar.
*/
const v = judge(d, orders({ austria: [mv('vie', 'gal')], russia: [sup('war', 'vie', 'gal')] }), board)
expect(v.every((x) => x.kept)).toBe(true)
})
})
describe('a demilitarised province', () => {
const d = deal('austria', 'russia', { kind: 'dmz', province: 'gal' })
it('holds when both stay out', () => {
const v = judge(d, orders({ austria: [hold('vie')], russia: [hold('war')] }), board)
expect(v.every((x) => x.kept)).toBe(true)
})
it('is broken by walking in', () => {
const v = judge(d, orders({ austria: [hold('vie')], russia: [mv('war', 'gal')] }), board)
expect(v.find((x) => x.power === 'russia')!.kept).toBe(false)
expect(v.find((x) => x.power === 'austria')!.kept).toBe(true)
})
})
describe('peace', () => {
const d = deal('austria', 'italy', { kind: 'peace' })
const neighbours = boardFrom([A('austria', 'tri'), A('italy', 'ven')])
it('holds while neither touches the other', () => {
const v = judge(d, orders({ austria: [mv('tri', 'alb')], italy: [mv('ven', 'pie')] }), neighbours)
expect(v.every((x) => x.kept)).toBe(true)
})
it('is broken by moving on a unit of theirs', () => {
const v = judge(d, orders({ austria: [hold('tri')], italy: [mv('ven', 'tri')] }), neighbours)
expect(v.find((x) => x.power === 'italy')!.kept).toBe(false)
expect(v.find((x) => x.power === 'italy')!.why).toContain('tri')
expect(v.find((x) => x.power === 'austria')!.kept).toBe(true)
})
it('is broken by helping yourself to an empty centre of theirs', () => {
/*
* The centre is undefended and nobody is home. That is not a loophole in
* peace, it is the most obvious way to break it, so ownership counts as
* well as occupation.
*/
const own = new Map([['tri', 'austria']]) as Map<string, Power>
const empty = boardFrom([A('italy', 'ven')])
const v = judge(d, orders({ italy: [mv('ven', 'tri')] }), empty, own)
expect(v.find((x) => x.power === 'italy')!.kept).toBe(false)
})
})
describe('a double deal', () => {
it('is two promises that cannot both be kept', () => {
const keepOut = deal('austria', 'russia', { kind: 'dmz', province: 'gal' })
const helpIn = deal('austria', 'turkey', {
kind: 'support',
mover: 'turkey',
helper: 'austria',
from: 'rum',
to: 'gal',
})
expect(conflicts(keepOut, helpIn)).toBe(true)
})
it('is not two promises that merely look alike', () => {
const a = deal('austria', 'russia', { kind: 'dmz', province: 'gal' })
const b = deal('austria', 'turkey', { kind: 'dmz', province: 'ser' })
expect(conflicts(a, b)).toBe(false)
})
it('is one unit promised to two different moves', () => {
const a = deal('austria', 'russia', {
kind: 'support',
mover: 'russia',
helper: 'austria',
from: 'war',
to: 'gal',
})
const b = deal('austria', 'turkey', {
kind: 'support',
mover: 'turkey',
helper: 'austria',
from: 'rum',
to: 'ser',
})
expect(conflicts(a, b)).toBe(true)
})
it('lets a power promise different turns freely', () => {
const a = deal('austria', 'russia', { kind: 'dmz', province: 'gal' }, 1)
const b = deal('austria', 'turkey', {
kind: 'support',
mover: 'turkey',
helper: 'austria',
from: 'rum',
to: 'gal',
}, 2)
expect(conflicts(a, b)).toBe(false)
})
})
describe('memory', () => {
const d = deal('austria', 'russia', { kind: 'dmz', province: 'gal' }, 3)
it('records a broken promise against the one who broke it', () => {
const v = judge(d, orders({ austria: [hold('vie')], russia: [mv('war', 'gal')] }), board)
const ledger = remember(emptyLedger(), v)
expect(look(ledger, 'austria', 'russia').broken).toBe(1)
expect(look(ledger, 'austria', 'russia').betrayals).toEqual([3])
// Austria kept it, and Russia noticed that too.
expect(look(ledger, 'russia', 'austria').kept).toBe(1)
})
it('does not tell anybody who was not there', () => {
const v = judge(d, orders({ austria: [hold('vie')], russia: [mv('war', 'gal')] }), board)
const ledger = remember(emptyLedger(), v)
expect(look(ledger, 'italy', 'russia')).toEqual({ kept: 0, broken: 0, betrayals: [] })
})
})
describe('trust', () => {
const stab = (turn: number) =>
judge(
deal('austria', 'russia', { kind: 'dmz', province: 'gal' }, turn),
orders({ austria: [hold('vie')], russia: [mv('war', 'gal')] }),
board,
)
const honour = (turn: number) =>
judge(
deal('austria', 'russia', { kind: 'dmz', province: 'gal' }, turn),
orders({ austria: [hold('vie')], russia: [hold('war')] }),
board,
)
it('gives a stranger the benefit of the doubt', () => {
expect(trust(emptyLedger(), 'austria', 'russia', 1)).toBeGreaterThan(0.5)
})
it('thinks better of somebody who keeps their word', () => {
let l = emptyLedger()
for (const t of [1, 2, 3]) l = remember(l, honour(t))
expect(trust(l, 'austria', 'russia', 4)).toBeGreaterThan(0.9)
})
it('drops hard on a betrayal', () => {
const l = remember(emptyLedger(), stab(1))
expect(trust(l, 'austria', 'russia', 1)).toBeLessThan(0.3)
})
it('lets an old betrayal fade without forgetting it', () => {
const l = remember(emptyLedger(), stab(1))
const fresh = trust(l, 'austria', 'russia', 2)
const stale = trust(l, 'austria', 'russia', 30)
expect(stale).toBeGreaterThan(fresh)
// Never fully: the count stays in the ledger for anybody who asks.
expect(look(l, 'austria', 'russia').broken).toBe(1)
})
it('weighs a stab against the promises kept before it', () => {
let loyal = emptyLedger()
for (const t of [1, 2, 3, 4, 5, 6]) loyal = remember(loyal, honour(t))
const afterLong = remember(loyal, stab(7))
const afterNothing = remember(emptyLedger(), stab(7))
expect(trust(afterLong, 'austria', 'russia', 7)).toBeGreaterThan(
trust(afterNothing, 'austria', 'russia', 7),
)
})
})
+246
View File
@@ -0,0 +1,246 @@
import { base, type Power } from './map'
import type { Board, Order } from './orders'
import type { Ownership } from './turn'
/**
* The press: what powers say to each other, and what it costs to lie.
*
* Every offer here is concrete and about a named turn. That is deliberate and
* it is the whole design. Free text would need a model to read it, could not
* be checked, and could not be tested; and a game where the machine decides
* by feel whether you kept your word is a game where being betrayed feels
* arbitrary rather than infuriating. These deals can be checked against the
* orders that were actually submitted, by arithmetic, every time.
*
* Three things a power can offer, because these are the three things that
* actually change a turn:
*
* - **support**, which wins a specific province on a specific turn;
* - **a demilitarised province**, which is how two powers agree to look
* somewhere else;
* - **peace**, which is the promise not to touch each other at all.
*
* And the important part: nothing here enforces anything. A deal is a
* sentence, orders are orders, and the gap between them is the game.
*/
export type Deal =
/** `helper` will support `mover`'s move from -> to. */
| { kind: 'support'; mover: Power; helper: Power; from: string; to: string }
/** Neither party enters this province. */
| { kind: 'dmz'; province: string }
/** Neither party moves against the other at all. */
| { kind: 'peace' }
export interface Proposal {
id: string
from: Power
to: Power
/** The turn it binds. A deal is always about a particular turn. */
turn: number
deal: Deal
}
export type Reply = 'accept' | 'refuse'
/** An accepted proposal. Binding on both parties, and on neither. */
export type Agreement = Proposal
export const parties = (a: Agreement): [Power, Power] => [a.from, a.to]
// ------------------------------------------------------------ double deals
/**
* Two agreements that cannot both be kept.
*
* This is the mechanic rather than a safeguard against one. Promising
* Galicia to Austria and supporting Russia into it on the same turn is not a
* mistake the interface should prevent -- it is a double deal, and somebody
* is going to find out. What this does is make it a *deliberate* act: a
* power can be shown, at the moment of agreeing, that it has now promised
* two things it cannot both do.
*/
export function conflicts(a: Agreement, b: Agreement): boolean {
if (a.turn !== b.turn) return false
const enters = (x: Agreement): string | null =>
x.deal.kind === 'support' ? base(x.deal.to) : null
const closed = (x: Agreement): string | null =>
x.deal.kind === 'dmz' ? base(x.deal.province) : null
// Supporting somebody into a province you have agreed to keep empty.
const one = enters(a)
const two = closed(b)
if (one !== null && two !== null && one === two) return true
const three = enters(b)
const four = closed(a)
if (three !== null && four !== null && three === four) return true
// Two different supports promised by the same power on the same turn are
// fine -- a power may have several units -- but the same unit cannot
// support two moves at once.
if (a.deal.kind === 'support' && b.deal.kind === 'support') {
if (a.deal.helper !== b.deal.helper) return false
if (a.deal.from === b.deal.from && a.deal.to === b.deal.to) return false
return true
}
return false
}
// -------------------------------------------------------------- keeping it
export interface Verdict {
agreement: Agreement
power: Power
kept: boolean
/** In the machine's own words, for the log and for the other power. */
why: string
}
/**
* Did each party do what it said it would?
*
* Judged on the **orders given**, never on how they turned out. A support
* that was ordered and then cut was still given; a move that was ordered and
* bounced was still attempted. Blaming a power for a bounce would make every
* alliance a lottery, and worse, would make an honest ally look like a liar
* for something the dice did.
*/
export function judge(
agreement: Agreement,
orders: ReadonlyMap<Power, readonly Order[]>,
board: Board,
/** Who owns which centre, so peace can cover the empty ones too. */
own?: Ownership,
): Verdict[] {
const deal = agreement.deal
const given = (p: Power) => orders.get(p) ?? []
if (deal.kind === 'support') {
const promised = given(deal.helper).some(
(o) =>
o.type === 'support' && base(o.from) === base(deal.from) && base(o.to) === base(deal.to),
)
const attempted = given(deal.mover).some(
(o) => o.type === 'move' && base(o.at) === base(deal.from) && base(o.to) === base(deal.to),
)
return [
{
agreement,
power: deal.helper,
kept: promised,
why: promised ? 'gave the support' : 'did not order the support',
},
{
agreement,
power: deal.mover,
kept: attempted,
why: attempted ? 'made the move' : 'did not make the move it asked help for',
},
]
}
if (deal.kind === 'dmz') {
const province = base(deal.province)
return parties(agreement).map((power) => {
const went = given(power).some((o) => o.type === 'move' && base(o.to) === province)
return {
agreement,
power,
kept: !went,
why: went ? `moved into ${province}` : `stayed out of ${province}`,
}
})
}
/*
* Peace. Touching anything of theirs breaks it: the provinces their units
* are standing in, and the centres they own even when nobody is standing
* there. Walking into an undefended centre and calling it peace because
* nobody was home is exactly the move this has to catch.
*/
return parties(agreement).map((power) => {
const them = power === agreement.from ? agreement.to : agreement.from
const theirs = new Set(
[...board.entries()].filter(([, u]) => u.power === them).map(([p]) => p),
)
if (own) for (const [p, who] of own) if (who === them) theirs.add(p)
const against = given(power).find((o) => o.type === 'move' && theirs.has(base(o.to)))
return {
agreement,
power,
kept: against === undefined,
why:
against && against.type === 'move'
? `moved on ${base(against.to)}`
: 'kept off them entirely',
}
})
}
// ------------------------------------------------------------------ memory
export interface Record_ {
kept: number
broken: number
/** The turns it went wrong, so a power can say when rather than how often. */
betrayals: number[]
}
/** What each power thinks of each other power. Directional, and it matters. */
export type Ledger = Map<string, Record_>
const key = (observer: Power, subject: Power) => `${observer}:${subject}`
export const emptyLedger = (): Ledger => new Map()
export function look(ledger: Ledger, observer: Power, subject: Power): Record_ {
return ledger.get(key(observer, subject)) ?? { kept: 0, broken: 0, betrayals: [] }
}
/**
* Write the turn into everybody's memory.
*
* Only the other party to a deal learns from it directly, which is the point
* of a game played in private. What the rest of the board knows is what it
* can see -- and that is handled elsewhere, by looking at the map, not here.
*/
export function remember(ledger: Ledger, verdicts: readonly Verdict[]): Ledger {
const next: Ledger = new Map(ledger)
for (const v of verdicts) {
const other = v.power === v.agreement.from ? v.agreement.to : v.agreement.from
const at = key(other, v.power)
const was = next.get(at) ?? { kept: 0, broken: 0, betrayals: [] }
next.set(at, {
kept: was.kept + (v.kept ? 1 : 0),
broken: was.broken + (v.kept ? 0 : 1),
betrayals: v.kept ? was.betrayals : [...was.betrayals, v.agreement.turn],
})
}
return next
}
/**
* How far one power will trust another, from nothing to complete.
*
* A power nobody has dealt with sits at 0.6 rather than 0.5: somebody you
* have never dealt with is a better bet than somebody who has already lied
* to you once, and a game where every stranger is treated as a proven liar
* never gets started at all.
*
* Betrayals also fade. Not to nothing -- the count never leaves the ledger --
* but a stab in 1901 should not still be the loudest fact about a power in
* 1910, or no alliance could ever re-form, and re-forming a broken alliance
* out of pure need is one of the best things this game does.
*/
export function trust(ledger: Ledger, observer: Power, subject: Power, turn: number): number {
const r = look(ledger, observer, subject)
if (r.kept === 0 && r.broken === 0) return 0.6
const weigh = (t: number) => 1 / (1 + Math.max(0, turn - t) / 8)
const hurt = r.betrayals.reduce((n, t) => n + weigh(t), 0)
const score = (r.kept - 2 * hurt) / (r.kept + r.broken)
return Math.max(0, Math.min(1, (score + 1) / 2))
}