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