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:
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user