From 9c09750d1ec4faa816a8a3fb7b7367585a454727 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 06:46:30 -0700 Subject: [PATCH] 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. --- src/game/press.test.ts | 232 ++++++++++++++++++++++++++++++++++++++ src/game/press.ts | 246 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 src/game/press.test.ts create mode 100644 src/game/press.ts diff --git a/src/game/press.test.ts b/src/game/press.test.ts new file mode 100644 index 0000000..75aba53 --- /dev/null +++ b/src/game/press.test.ts @@ -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>): Map => + 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 + 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), + ) + }) +}) diff --git a/src/game/press.ts b/src/game/press.ts new file mode 100644 index 0000000..2929e62 --- /dev/null +++ b/src/game/press.ts @@ -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, + 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 + +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)) +}