From 69adcc8f89a6725c3aa4989ecdcf3454f9b7f80a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 08:46:24 -0700 Subject: [PATCH] The press, in front of the player The board is the game being played now rather than a picture of the opening: the loop runs, powers approach you before every set of orders, and what you agree is judged when the orders come in. Approaches arrive as sentences with Agree and Refuse under them, because a proposal is a thing somebody said and should not look like a row in a form. Asking is done from an order already written -- I am going there, help me -- which is the only shape this request has ever taken, and the answer comes back immediately in the power's own words. Those words are now written for a person. A power that turns you down saying 'austria is worth more to me than boh' is telling the truth in a voice nobody uses; it says Austria, and Bohemia, and puts a full stop on the end. The turn log too. Nothing in the panel enforces anything. Agreeing writes no order and stops no order being written, and the gap between what was agreed and what was ordered is the whole game -- which is why the panel also shows, per power, how many promises you have kept and broken and how far each of them now believes you. Retreats and builds are still taken for the player automatically, the same way a computer power's are. Choosing them is the next piece. --- src/App.css | 85 +++++++++++++- src/App.tsx | 205 +++++++++++++++++++++------------- src/components/PressPanel.tsx | 149 ++++++++++++++++++++++++ src/game/bot.test.ts | 3 +- src/game/bot.ts | 29 +++-- src/game/game.test.ts | 2 +- src/game/game.ts | 27 ++++- 7 files changed, 404 insertions(+), 96 deletions(-) create mode 100644 src/components/PressPanel.tsx diff --git a/src/App.css b/src/App.css index 0a61bd1..521ecb8 100644 --- a/src/App.css +++ b/src/App.css @@ -215,6 +215,87 @@ header select { .powers li.me { color: #ffd479; } +/* --- the press ------------------------------------------------------- */ + +.press h2 { margin: 0 0 4px; font-size: 0.95rem; color: #ffd479; } +.press h3 { margin: 8px 0 3px; font-size: 0.8rem; letter-spacing: 0.04em; color: #a3b3c9; } + +.small { font-size: 0.78rem; } + +.asks { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px; } + +.asks li { + border: 2px solid #2b2318; + border-radius: 8px; + background: rgba(34, 48, 74, 0.55); + padding: 6px 8px; +} + +.who { margin: 0; font-size: 0.74rem; font-weight: 800; letter-spacing: 0.06em; color: #a3b3c9; } + +/* Their words, set as words. A proposal is a thing somebody said, and it + should not look like a row in a form. */ +.quote { margin: 2px 0 6px; font-size: 0.84rem; line-height: 1.35; font-style: italic; } + +.quote.reply { color: #ffd479; margin-top: 6px; } + +.answer { display: flex; gap: 5px; } + +.answer button, +.ask-row button { + font: inherit; + font-size: 0.76rem; + font-weight: 700; + color: #f2e9d8; + background: #2f5d3a; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 2px 9px; + cursor: pointer; +} + +.answer button.no { background: #6d2f28; } + +.asking { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } + +.ask-row { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; } + +.ask-row .whoms { margin-left: auto; display: flex; gap: 2px; } + +.ask-row button { background: #22304a; padding: 1px 5px; font-size: 0.7rem; } + +.deals { margin: 0; padding: 0; list-style: none; font-size: 0.8rem; } +.deals li { padding: 1px 0; } + +.opinions { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 3px; } + +.opinions li { display: grid; grid-template-columns: 5.5em 1fr 44px; align-items: center; gap: 6px; font-size: 0.78rem; } + +/* How far each power believes you, which is the only number in this game + that a player earns rather than takes. */ +.opinions .bar { + height: 7px; + border: 1.5px solid #2b2318; + border-radius: 999px; + background: #16202f; + overflow: hidden; +} + +.opinions .bar i { display: block; height: 100%; background: #ffd479; } + +.log { + margin: 0; + padding: 0; + list-style: none; + font-size: 0.76rem; + line-height: 1.4; + color: #a3b3c9; + border-top: 1px solid #2b3a52; + padding-top: 6px; +} + +.powers li.gone { opacity: 0.4; text-decoration: line-through; } + /* --- the side ------------------------------------------------------- */ .side { @@ -222,7 +303,9 @@ header select { min-height: 0; display: flex; flex-direction: column; - gap: 14px; + gap: 12px; + overflow-y: auto; + padding-right: 4px; } .powers { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 5px; } diff --git a/src/App.tsx b/src/App.tsx index 052dab1..5451292 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,68 +1,76 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Board, COLOURS, POWER_NAMES } from './components/Board' import { OrderPanel, type Step } from './components/OrderPanel' +import { PressPanel } from './components/PressPanel' +import { consider, type Overture } from './game/bot' +import { + LAST_YEAR, + negotiate, + newGame, + resolveBuilds, + resolveOrders, + resolveRetreats, + turnOf, + type Game, +} from './game/game' import { reachableFrom } from './game/layout' -import { OPENING, POWERS, PROVINCES, base, type Power } from './game/map' -import { boardFrom, canStep, validate, type Order, type Unit } from './game/orders' -import { centreCount, openingOwnership } from './game/turn' +import { POWERS, PROVINCES, base, type Power } from './game/map' +import { canStep, validate, type Order, type Unit } from './game/orders' +import type { Proposal } from './game/press' +import { centreCount } from './game/turn' import './App.css' /** - * Spring 1901, with orders. + * The game, being played. * - * The whole interaction is clicks on the board, because that is where the - * question is. A unit is selected, a verb is chosen, and the provinces the - * next click will accept light up -- so a player never types a move that the - * rules were never going to allow, and never has to learn a notation to find - * that out. - * - * Everything is offered from the rules rather than from the map. A fleet on - * the north coast of Spain is drawn identically to one on the south coast and - * they can go to entirely different places, which no shape can express. + * Everything is clicks on the board or answers in the panel, because those + * are the only two things this game asks of anybody: where are your units + * going, and do you mean what you told Russia. */ export default function App() { const [power, setPower] = useState('austria') + const [game, setGame] = useState(newGame) + const [asked, setAsked] = useState([]) const [step, setStep] = useState({ kind: 'idle' }) const [orders, setOrders] = useState>(new Map()) - const units = useMemo(() => { - const all: Unit[] = [] - for (const p of POWERS) { - for (const at of OPENING[p].armies) all.push({ power: p, type: 'army', at }) - for (const at of OPENING[p].fleets) all.push({ power: p, type: 'fleet', at }) - } - return boardFrom(all) - }, []) + /* + * The talking happens once per orders phase. Keeping a note of which turn + * has been negotiated matters more than it looks: agreeing a deal changes + * the game, and a re-run on every change would have the powers proposing + * to each other in a loop for ever. + */ + const talked = useRef('') + useEffect(() => { + const key = `${power}-${game.year}-${game.season}-${game.phase}` + if (game.phase !== 'orders' || talked.current === key) return + talked.current = key + const round = negotiate(game, power) + setGame(round.game) + setAsked(round.asked) + }, [game, power]) - const own = useMemo(openingOwnership, []) + const own = game.own + const units = game.board + const pos = useMemo(() => ({ board: units, own }), [units, own]) - /** Which provinces the next click will do something with. */ const offering = useMemo(() => { if (step.kind === 'idle') return new Set() const unit = units.get(step.at) if (!unit) return new Set() - if (step.kind === 'move') return new Set(reachableFrom(unit).map(base)) - if (step.kind === 'support') { if (step.from === undefined) { - // Anybody I could reach: I can prop them up where they stand, or - // help them into somewhere I could have gone myself. - return new Set( - [...units.keys()].filter((p) => p !== step.at && canReach(unit, p)), - ) + return new Set([...units.keys()].filter((p) => p !== step.at && canReach(unit, p))) } const helped = units.get(step.from) if (!helped) return new Set() - // Only where both of us could go: you cannot support a move you could - // not have made yourself. return new Set( reachableFrom(helped) .map(base) .filter((p) => canReach(unit, p) || p === step.from), ) } - if (step.from === undefined) { return new Set( [...units.entries()] @@ -82,32 +90,20 @@ export default function App() { const click = (province: string) => { const here = units.get(province) - - // Starting again: clicking one of my own units always selects it. if (step.kind === 'idle' || !offering.has(province)) { if (here?.power === power) setStep({ kind: 'move', at: province }) else setStep({ kind: 'idle' }) return } - if (step.kind === 'move') { write({ type: 'move', at: step.at, to: coastOf(units.get(step.at)!, province), power }) return } - if (step.kind === 'support') { - if (step.from === undefined) { - setStep({ ...step, from: province }) - return - } - write({ type: 'support', at: step.at, from: step.from, to: province, power }) - return - } - - if (step.from === undefined) { - setStep({ ...step, from: province }) - return + if (step.from === undefined) return setStep({ ...step, from: province }) + return write({ type: 'support', at: step.at, from: step.from, to: province, power }) } + if (step.from === undefined) return setStep({ ...step, from: province }) write({ type: 'convoy', at: step.at, from: step.from, to: province, power }) } @@ -120,31 +116,77 @@ export default function App() { setStep({ kind: 'idle' }) } - // What the rules make of the orders so far, live. A player should find out - // that a support is impossible while writing it, not after the turn. - const illegal = useMemo( - () => validate(units, [...orders.values()]).illegal, - [units, orders], - ) + const illegal = useMemo(() => validate(units, [...orders.values()]).illegal, [units, orders]) + + /** Yes or no to somebody's approach. Neither answer binds anybody. */ + const answer = (overture: Overture, yes: boolean) => { + setAsked((prev) => prev.filter((o) => o.proposal.id !== overture.proposal.id)) + if (yes) setGame((g) => ({ ...g, agreements: [...g.agreements, overture.proposal] })) + } + + /** Ask a power to support a move already written, and hear back at once. */ + const ask = (to: Power, order: Extract): string => { + const proposal: Proposal = { + id: `${power}-${to}-${turnOf(game)}-${base(order.to)}`, + from: power, + to, + turn: turnOf(game), + deal: { kind: 'support', mover: power, helper: to, from: order.at, to: order.to }, + } + const theirs = { power: to, ledger: game.ledger, agreements: game.agreements } + const reply = consider(pos, theirs, proposal, turnOf(game)) + if (reply.reply === 'accept') { + setGame((g) => ({ ...g, agreements: [...g.agreements, proposal] })) + } + return reply.why + } + + const submit = () => { + let next = resolveOrders(game, power, [...orders.values()]) + // The player's own retreats and builds are taken for them for now, the + // same way a computer power's are. Choosing them is the next piece. + while (next.phase === 'retreats' || next.phase === 'builds') { + next = next.phase === 'retreats' + ? resolveRetreats(next, power, []) + : resolveBuilds(next, power, []) + } + setOrders(new Map()) + setStep({ kind: 'idle' }) + setAsked([]) + setGame(next) + } const mine = [...units.entries()].filter(([, u]) => u.power === power) + const season = game.season === 'spring' ? 'Spring' : 'Autumn' return (

Great Powers

- Spring 1901 — playing{' '} - - . {mine.length - orders.size} of {mine.length} units still without orders. + {game.phase === 'over' + ? game.winner + ? `${POWER_NAMES[game.winner]} has eighteen centres. That is the game.` + : `Called at the end of ${game.year}. A draw between the survivors.` + : `${season} ${game.year} of ${LAST_YEAR} — playing `} + {game.phase !== 'over' && ( + + )} + {game.phase !== 'over' && + `. ${mine.length - orders.size} of ${mine.length} units still without orders.`}

@@ -162,7 +204,7 @@ export default function App() {
) } -/** Can this unit reach that province, by any coast of it? */ function canReach(unit: Unit, province: string): boolean { if (canStep(unit, province)) return true const coasts = PROVINCES[province]?.coasts return coasts !== undefined && coasts.some((c) => canStep(unit, `${province}/${c}`)) } -/** - * Which coast a fleet means. Where only one is reachable the order is - * unambiguous even though it did not say, which is the rule the adjudicator - * applies too -- so the interface should not make a fuss about it either. - */ function coastOf(unit: Unit, province: string): string { const coasts = PROVINCES[province]?.coasts if (unit.type === 'army' || !coasts) return province diff --git a/src/components/PressPanel.tsx b/src/components/PressPanel.tsx new file mode 100644 index 0000000..393bd89 --- /dev/null +++ b/src/components/PressPanel.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react' +import { POWER_NAMES } from './Board' +import type { Overture } from '../game/bot' +import { PROVINCES, POWERS, base, type Power } from '../game/map' +import type { Order } from '../game/orders' +import { look, trust, type Agreement, type Ledger } from '../game/press' + +/** + * What the powers are saying to you, and what you say back. + * + * The whole point of this game is here rather than on the board. A power + * asks for something in a sentence, you answer yes or no, and at the end of + * the turn the orders say whether either of you meant it. Nothing in this + * panel enforces anything -- accepting a deal writes no order and stops no + * order being written. That gap is the game. + */ + +export function PressPanel({ + power, + turn, + ledger, + asked, + agreements, + orders, + onAnswer, + onAsk, +}: { + power: Power + turn: number + ledger: Ledger + /** Approaches made to the player this turn. */ + asked: readonly Overture[] + agreements: readonly Agreement[] + /** The player's own orders, so help can be asked for a move already written. */ + orders: ReadonlyMap + onAnswer: (overture: Overture, yes: boolean) => void + onAsk: (to: Power, order: Extract) => string + }) { + const [said, setSaid] = useState<{ to: Power; reply: string } | null>(null) + + const mine = agreements.filter((a) => a.from === power || a.to === power) + const moves = [...orders.values()].filter( + (o): o is Extract => o.type === 'move', + ) + + return ( +
+

The press

+ + {asked.length === 0 ? ( +

Nobody has approached you this turn.

+ ) : ( +
    + {asked.map((o) => ( +
  • +

    {POWER_NAMES[o.proposal.from]} says

    +

    “{o.says}”

    +
    + + +
    +
  • + ))} +
+ )} + + {/* + Asking is done from an order already written, because that is the + only shape this request has ever taken: I am going there, help me. + */} + {moves.length > 0 && ( +
+

Ask for help with:

+ {moves.map((o) => ( +
+ + {base(o.at)} → {base(o.to)} + + + {POWERS.filter((p) => p !== power).map((p) => ( + + ))} + +
+ ))} + {said && ( +

+ {POWER_NAMES[said.to]}: “{said.reply}” +

+ )} +
+ )} + + {mine.length > 0 && ( + <> +

Agreed this turn

+
    + {mine.map((a) => ( +
  • {describe(a, power)}
  • + ))} +
+ + )} + +

What they make of you

+
    + {POWERS.filter((p) => p !== power).map((p) => { + const r = look(ledger, p, power) + return ( +
  • + {POWER_NAMES[p]} + + {r.kept + r.broken === 0 + ? 'no dealings' + : `${r.kept} kept, ${r.broken} broken`} + + + + +
  • + ) + })} +
+
+ ) +} + +function describe(a: Agreement, me: Power): string { + const them = a.from === me ? a.to : a.from + const who = POWER_NAMES[them] + switch (a.deal.kind) { + case 'peace': + return `Peace with ${who}.` + case 'dmz': + return `${PROVINCES[base(a.deal.province)]!.name} left empty, with ${who}.` + case 'support': + return a.deal.helper === me + ? `You support ${who}: ${base(a.deal.from)} → ${base(a.deal.to)}.` + : `${who} supports you: ${base(a.deal.from)} → ${base(a.deal.to)}.` + } +} diff --git a/src/game/bot.test.ts b/src/game/bot.test.ts index 2897ebf..633aead 100644 --- a/src/game/bot.test.ts +++ b/src/game/bot.test.ts @@ -260,7 +260,8 @@ describe('a bot doing the asking', () => { expect(ask).toBeDefined() expect(ask!.proposal.to).toBe('england') - expect(ask!.says).toContain('bel') + // The sentence is written for a person to read, so it says Belgium. + expect(ask!.says).toContain('Belgium') }) it('never asks the power it is attacking to help it', () => { diff --git a/src/game/bot.ts b/src/game/bot.ts index 2b4dfb6..644a074 100644 --- a/src/game/bot.ts +++ b/src/game/bot.ts @@ -321,6 +321,15 @@ export interface Answer { why: string } +/* + * These sentences are read by a person, so they are written for one: proper + * names, and the province called what it is called on the map. A power that + * turns you down saying "austria is worth more to me than boh" is telling + * you the truth in a voice nobody uses. + */ +const named = (power: Power): string => power[0]!.toUpperCase() + power.slice(1) +const place = (id: string): string => PROVINCES[base(id)]!.name + /** * Whether to accept an offer. * @@ -338,30 +347,30 @@ export function consider(pos: Position, mind: Mind, proposal: Proposal, turn: nu if (deal.mover === mind.power) { const worth = desire(pos, mind.power, deal.to) * believe return worth > FRIEND - ? { reply: 'accept', why: `${base(deal.to)} is worth having and ${them} may mean it` } - : { reply: 'refuse', why: `${base(deal.to)} is not worth owing ${them} for` } + ? { reply: 'accept', why: `${place(deal.to)} is worth having, and ${named(them)} may mean it.` } + : { reply: 'refuse', why: `${place(deal.to)} is not worth owing ${named(them)} for.` } } // They want my support. It costs me a unit's turn, and buys goodwill. const cost = desire(pos, mind.power, deal.to) const worth = believe * (FRIEND + NEIGHBOUR) return worth > cost - ? { reply: 'accept', why: `${them} is worth more to me than ${base(deal.to)}` } - : { reply: 'refuse', why: `I want ${base(deal.to)} myself` } + ? { reply: 'accept', why: `${named(them)} is worth more to me than ${place(deal.to)}.` } + : { reply: 'refuse', why: `I want ${place(deal.to)} for myself.` } } if (deal.kind === 'dmz') { const mine = desire(pos, mind.power, deal.province) const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR return relief * believe > mine - ? { reply: 'accept', why: `keeping ${base(deal.province)} empty suits me` } - : { reply: 'refuse', why: `I have plans for ${base(deal.province)}` } + ? { reply: 'accept', why: `Keeping ${place(deal.province)} empty suits me.` } + : { reply: 'refuse', why: `I have plans for ${place(deal.province)}.` } } const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR * believe const behind = standing(pos, them) - standing(pos, mind.power) return relief > 0 && behind < 3 * 100 - ? { reply: 'accept', why: `a quiet border with ${them} is worth more than the fight` } - : { reply: 'refuse', why: `${them} is either no threat or too far ahead to be trusted` } + ? { reply: 'accept', why: `A quiet border with ${named(them)} is worth more than the fight.` } + : { reply: 'refuse', why: `${named(them)} is either no threat, or too far ahead to trust.` } } /** How many of our centres this power is standing next to. */ @@ -451,7 +460,7 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] { to: target.to, }, }, - says: `Support my ${base(target.from)} into ${target.id} and it is mine this turn.`, + says: `Support my ${place(target.from)} into ${place(target.id)}, and it is mine this turn.`, }) } @@ -471,7 +480,7 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] { turn, deal: { kind: 'dmz', province: between }, }, - says: `Neither of us needs ${between}. Leave it empty and we both look elsewhere.`, + says: `Neither of us needs ${place(between)}. Leave it empty and we both look elsewhere.`, }) } diff --git a/src/game/game.test.ts b/src/game/game.test.ts index a7346b5..861df64 100644 --- a/src/game/game.test.ts +++ b/src/game/game.test.ts @@ -197,7 +197,7 @@ describe('the press, over a turn', () => { // Austria promised Galicia would stay empty and marches straight in. const orders: Order[] = [{ type: 'move', at: 'vie', to: 'gal', power: 'austria' }] const after = resolveOrders(g, PLAYER, orders) - expect(after.log.join(' ')).toMatch(/austria broke its word/) + expect(after.log.join(' ')).toMatch(/Austria broke its word/) expect(look(after.ledger, 'russia', 'austria').broken).toBe(1) }) diff --git a/src/game/game.ts b/src/game/game.ts index 58c67f0..b246bb4 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -192,7 +192,10 @@ export function resolveOrders( const verdicts = judge(deal, byPower, g.board, g.own) ledger = remember(ledger, verdicts) for (const v of verdicts) { - if (!v.kept) broken.push(`${v.power} broke its word: ${v.why}.`) + if (!v.kept) { + const who = v.power[0]!.toUpperCase() + v.power.slice(1) + broken.push(`${who} broke its word: ${v.why}.`) + } } } @@ -231,7 +234,11 @@ export function resolveRetreats( const { board, disbanded } = applyRetreats(g.board, g.outcome, orders) const log = [ ...g.log, - ...disbanded.map((u) => `${u.power} loses its ${u.type} in ${base(u.at)}: nowhere to go.`), + ...disbanded.map( + (u) => + `${u.power[0]!.toUpperCase()}${u.power.slice(1)} loses a ${u.type} in ` + + `${PROVINCES[base(u.at)]!.name}: nowhere to go.`, + ), ] return afterRetreats({ ...g, board, log, outcome: null }) } @@ -254,8 +261,12 @@ function afterRetreats(g: Game): Game { const log = [ ...g.log, - ...gone.map((p) => `${p} is finished: no centres left.`), - ...(winner ? [`${winner} holds ${centreCount(own, winner)} centres. That is the game.`] : []), + ...gone.map((p) => `${p[0]!.toUpperCase()}${p.slice(1)} is finished: no centres left.`), + ...(winner + ? [ + `${winner[0]!.toUpperCase()}${winner.slice(1)} holds ${centreCount(own, winner)} centres. That is the game.`, + ] + : []), ] if (winner) return { ...g, own, out, winner, phase: 'over', log } @@ -344,15 +355,19 @@ export function resolveBuilds( // ------------------------------------------------------------------ saying +/** The turn, in the words a player would use for it. */ function report(outcome: Outcome, orders: readonly Order[]): string[] { const said: string[] = [] + const name = (id: string) => PROVINCES[base(id)]!.name + for (const o of orders) { if (o.type !== 'move') continue const at = base(o.at) - if (outcome.success.get(at)) said.push(`${at} takes ${base(o.to)}.`) + if (outcome.success.get(at)) said.push(`${name(at)} takes ${name(o.to)}.`) } for (const [at, d] of outcome.dislodged) { - said.push(`${d.unit.power}'s ${d.unit.type} is thrown out of ${at}.`) + const who = d.unit.power[0]!.toUpperCase() + d.unit.power.slice(1) + said.push(`${who} is thrown out of ${name(at)}.`) } if (said.length === 0) said.push('Nothing moved.') return said