diff --git a/README.md b/README.md index 136e354..c544634 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,51 @@ sits there in red until `F North Sea convoys Yorkshire → Norway` joins it, and then both go black. Nothing was added to make that happen. The validator already knew. +## Before the map, and how to stop + +A landing page, because Diplomacy has a reputation and about half of it is +wrong. The rules are short enough to state completely and strange enough +that stating them is a courtesy: there is no luck in this game at all, every +unit is exactly as strong as every other, and nothing happens on the board +that somebody did not agree to help with. It is also the honest place for +the licence and for where the map came from -- a page that says nothing +about that is a page quietly hoping nobody asks. + +The dice that play when you start are the only joke in here. There are no +dice in Diplomacy; that is the sound of every other board game you have +played, and the last one you hear before finding out that none of this is +luck. + +Giving up is two different things and `src/game/concede.ts` keeps them +apart. + +**Resigning is unilateral.** Nobody is entitled to your attention, so nobody +votes on it. But your power does not leave the board: it goes into civil +disorder, holds everything, waives its builds and is paid off as it loses +centres, which is what the rules already say happens to an absent player. +That matters to everybody else. A resigned Austria is still eleven centres +somebody has to go and take, and letting them evaporate would hand the game +to whoever happened to be next door. + +**A draw is not unilateral**, and that is the part that keeps it Diplomacy +rather than a menu. Every surviving power votes and one refusal is enough, +because being able to end the game by asking would be worth more than any +alliance in it. A power refuses while it leads or is within two centres of +leading, and it says so: *"England is 1 behind and thinks that is +catchable."* Asked in Spring 1901 every one of the other six refuses, which +is the right answer -- a game that could be drawn on the first turn would +not be worth playing. + +**Conceding** -- handing the game to whoever is winning -- is harder to get +than a draw, and it should be. It only carries when the solo was coming +anyway: the leader is within four of eighteen and nobody left can close the +gap. Otherwise refusing costs nothing but time, and time is the only thing +the losing side has. + +Elimination is the third way out and needs no button. When your last centre +goes you are told so and the rest plays out; you can watch it or close the +tab, and either way the board is still worth seeing. + ## Still to build - bots strong enough to solo against each other, not only to draw diff --git a/src/App.css b/src/App.css index d08dbb7..37d2c05 100644 --- a/src/App.css +++ b/src/App.css @@ -391,3 +391,118 @@ header select { grid-template-areas: 'head' 'map' 'side'; } } + +/* ------------------------------------------------------------- the landing */ + +/* + * Read before played. Two columns of rules on anything wide enough, one on + * anything narrower, and the powers as a row of swatches so the choice looks + * like what it is -- picking a colour on a map. + */ +.landing { + max-width: 1100px; + margin: 0 auto; + padding: clamp(16px, 4vw, 48px); + line-height: 1.55; +} + +.landing header { display: block; margin-bottom: 22px; } +.landing h1 { font-size: clamp(2rem, 6vw, 3.4rem); } +.landing .tagline { font-size: 1.1rem; color: #a3b3c9; margin: 6px 0 0; } + +.landing .cols { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 4px 36px; +} + +.landing h2 { + font-size: 1.05rem; + color: #ffd479; + margin: 22px 0 6px; + letter-spacing: 0.02em; +} +.landing p { margin: 0 0 10px; } +.landing strong { color: #f2e9d8; } +.landing a { color: #7fc4e8; } + +.landing .choose { margin-top: 26px; } +.landing .choose ul { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; + padding: 0; +} +.landing .choose button { + font: inherit; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; + color: #f2e9d8; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 8px; + padding: 6px 12px; + cursor: pointer; +} +.landing .choose button.on { background: #3c527a; box-shadow: inset 0 0 0 2px #ffd479; } + +/* More specific than the swatch buttons above it, which it sits beside. */ +.landing .choose .start { + font: inherit; + font-size: 1.05rem; + font-weight: 800; + color: #241a10; + background: #ffd479; + border: 2px solid #2b2318; + border-radius: 8px; + padding: 10px 22px; + cursor: pointer; +} + +.landing footer { + margin-top: 34px; + padding-top: 16px; + border-top: 1px solid #2f3b52; + font-size: 0.82rem; + color: #8c98ab; +} +.landing footer p { margin: 0 0 8px; } + +/* ----------------------------------------------------------- giving up */ + +.giving-up { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 4px; +} +.giving-up button { + font: inherit; + font-size: 0.82rem; + font-weight: 700; + color: #cbd6e6; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 3px 9px; + cursor: pointer; +} +.giving-up .resign { color: #ffb3a7; } +.giving-up p { margin: 0; font-size: 0.85rem; } + +/* What the refusals said. They are arguments, not error messages. */ +.said { + flex-basis: 100%; + list-style: none; + margin: 4px 0 0; + padding: 0; + font-size: 0.82rem; + color: #d8c48a; +} +.said li { margin: 2px 0; } + +.watch { margin-top: 8px; } diff --git a/src/App.tsx b/src/App.tsx index 9fe30b8..7c88a6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,16 @@ import { synth } from './audio/synth' import { Board, COLOURS, POWER_NAMES } from './components/Board' import { BuildPanel, RetreatPanel } from './components/AdjustPanel' import { OrderPanel, type Step } from './components/OrderPanel' +import { Landing } from './components/Landing' import { PressPanel } from './components/PressPanel' import { consider, type Overture } from './game/bot' import { LAST_YEAR, negotiate, newGame, + askForDraw, + askToConcede, + resign, resolveBuilds, resolveOrders, resolveRetreats, @@ -48,6 +52,9 @@ export default function App() { const [retreats, setRetreats] = useState>(new Map()) const [builds, setBuilds] = useState([]) const [sound, setSound] = useState(true) + const [started, setStarted] = useState(false) + const [said, setSaid] = useState([]) + const [sure, setSure] = useState(false) // ------------------------------------------------------------ audio glue @@ -57,7 +64,7 @@ export default function App() { * before that is silent whatever the toggle says, which is the rule and * not a bug. */ - const started = useRef(false) + const woken = useRef(false) /* * Which piece is playing is not decoration: spring is the march, autumn is @@ -67,8 +74,8 @@ export default function App() { const tune = game.phase === 'over' ? ARMISTICE : game.season === 'spring' ? THE_FRONT : THE_PUSH const wake = useCallback(() => { - if (started.current || !sound) return - started.current = true + if (woken.current || !sound) return + woken.current = true synth.ensure() synth.playTune(tune, false) }, [sound, tune]) @@ -79,7 +86,7 @@ export default function App() { }, [sound]) useEffect(() => { - if (started.current) synth.playTune(tune, false) + if (woken.current) synth.playTune(tune, false) }, [tune]) /** The ending, once, when there is one. */ @@ -99,12 +106,12 @@ export default function App() { const talked = useRef('') useEffect(() => { const key = `${power}-${game.year}-${game.season}-${game.phase}` - if (game.phase !== 'orders' || talked.current === key) return + if (!started || game.phase !== 'orders' || talked.current === key) return talked.current = key const round = negotiate(game, power) setGame(round.game) setAsked(round.asked) - }, [game, power]) + }, [game, power, started]) const own = game.own const units = game.board @@ -253,6 +260,10 @@ export default function App() { const advance = useCallback( (from: Game): Game => { let g = from + // A player who has resigned or been eliminated is not asked anything + // again. The turns still have to happen: the rest of the board is + // playing for a win and the result is worth watching. + const done = g.resigned.includes(power) || g.out.includes(power) /* * Each phase reports its own sounds, so a submission that runs through * three of them would otherwise arrive with only the last one's. They @@ -264,13 +275,13 @@ export default function App() { const mineBeaten = [...(g.outcome?.dislodged.values() ?? [])].some( (d) => d.unit.power === power, ) - if (mineBeaten) break + if (mineBeaten && !done) break g = resolveRetreats(g, power, []) heard = mergeCues(heard, g.sounds) continue } if (g.phase === 'builds') { - if (adjustmentFor(g.own, g.board, power) !== 0) break + if (!done && adjustmentFor(g.own, g.board, power) !== 0) break g = resolveBuilds(g, power, []) heard = mergeCues(heard, g.sounds) continue @@ -296,6 +307,44 @@ export default function App() { setRetreats(new Map()) } + /* + * Giving up, in its two shapes. See `concede.ts`: resigning needs nobody's + * permission and a draw needs everybody's, and the refusals come back with + * their reasons so they can be argued with next year. + */ + const giveUp = () => { + // Asked twice, in the page rather than in a browser dialog: this is the + // one button here that cannot be taken back. + if (!sure) { + setSure(true) + if (synth.sfxOn) synth.reject() + return + } + if (synth.sfxOn) synth.disband() + setGame(resign(game, power)) + setSure(false) + setSaid([]) + } + + const askDraw = () => { + setSure(false) + if (synth.sfxOn) synth.telegraph(false) + const { game: next, verdicts } = askForDraw(game, power) + setGame(next) + setSaid(verdicts.filter((v) => !v.agree).map((v) => v.why)) + } + + const askConcede = () => { + setSure(false) + const leader = POWERS.reduce((best, p) => + centreCount(own, p) > centreCount(own, best) ? p : best, + ) + if (synth.sfxOn) synth.telegraph(false) + const { game: next, verdicts } = askToConcede(game, power, leader) + setGame(next) + setSaid(verdicts.filter((v) => !v.agree).map((v) => v.why)) + } + const doneBuilding = () => { setGame(advance(resolveBuilds(game, power, builds))) setBuilds([]) @@ -303,6 +352,25 @@ export default function App() { const mine = [...units.entries()].filter(([, u]) => u.power === power) const season = game.season === 'spring' ? 'Spring' : 'Autumn' + const quit = game.resigned.includes(power) || game.out.includes(power) + + if (!started) { + return ( + { + wake() + if (synth.sfxOn) synth.tap() + setPower(p) + }} + onStart={() => { + wake() + if (synth.sfxOn) synth.dice() + setStarted(true) + }} + /> + ) + } return (
@@ -343,7 +411,9 @@ export default function App() { )} {game.phase === 'orders' && - `. ${mine.length - orders.size} of ${mine.length} units still without orders.`} + (quit + ? '. Watching.' + : `. ${mine.length - orders.size} of ${mine.length} units still without orders.`)} {game.phase === 'retreats' && '. Somebody of yours was thrown out.'} {game.phase === 'builds' && '. The winter.'}

@@ -373,7 +443,7 @@ export default function App() { ))} - {game.phase === 'retreats' && game.outcome && ( + {game.phase === 'retreats' && game.outcome && !quit && ( )} - {game.phase === 'builds' && ( + {game.phase === 'builds' && !quit && ( )} - {game.phase === 'orders' && ( + {game.phase === 'orders' && !quit && ( )} - {game.phase === 'orders' && ( + {quit && game.phase !== 'over' && ( + + )} + + {game.phase !== 'over' && ( +
+ {quit ? ( +

+ {game.out.includes(power) + ? 'You have no centres left. The rest plays out without you.' + : 'You have resigned. Your units hold where they stand.'} +

+ ) : ( + <> + + + + + )} + {said.length > 0 && ( +
    + {said.map((why, i) => ( +
  • {why}
  • + ))} +
+ )} +
+ )} + + {game.phase === 'orders' && !quit && ( void + onStart: () => void +}) { + return ( +
+
+

Great Powers

+

+ Europe, 1901. Seven of you, thirty-four supply centres, and no dice. +

+
+ +
+
+

The whole game

+

+ You have armies and fleets. Every spring and every autumn, each of them gets one + order: hold, move to a neighbouring province, support somebody else's move or hold, + or — for a fleet at sea — carry an army across. +

+

+ Every unit is exactly as strong as every other. One unit cannot push + another out of a province. Two can. So nothing happens on this board that somebody + did not agree to help with, and the game is the agreeing. +

+

+ All orders are carried out at once. A move into an occupied province fails unless it + has more support than the defence; equal strength bounces and both stay put. A unit + thrown out retreats or is gone. +

+

+ In autumn, whoever is standing on a supply centre owns it. Own more centres than you + have units and you build over the winter; own fewer and you pay units off. +

+
+ +
+

Winning, and stopping

+

+ {SOLO} of the 34 centres wins outright. Nobody has ever managed that + without help, and nobody has ever been helped to it on purpose — which is the whole + problem, and the whole game. +

+

+ Otherwise it runs to the end of {LAST_YEAR} and the survivors draw. You may ask for + that draw earlier, but every power still standing has to agree, and a power that + fancies its chances will say no and tell you why. +

+

+ You may also resign. That is yours alone to decide and nobody votes on it — but your + power does not leave the board. It holds where it stands and is paid off as it loses + centres, because eleven abandoned centres are eleven centres somebody has to go and + take. +

+

Talking

+

+ The other six will approach you with deals and will answer yours. Nothing anybody + agrees to is binding. Breaking your word is a legal move, and it is remembered. +

+
+
+ +
+

Take a power

+
    + {POWERS.map((p) => ( +
  • + +
  • + ))} +
+ +
+ +
+

+ Great Powers is free software under the{' '} + GNU Affero General Public License, + version 3 or later. It comes with no warranty whatsoever. You may use, study, change + and share it; if you run a changed version where other people can reach it, they are + entitled to its source. +

+

+ The map is derived from the standard map of the{' '} + diplomacy project, also AGPL, and + the adjudicator is tested against Lucas Kruijswijk's Diplomacy Adjudicator Test Cases. + Neither is affiliated with this. See NOTICE.md. +

+

+ This is not the board game, and is not connected to the people who publish it. No + artwork, text or trademark of theirs is used here: every mark on the screen and every + note of the music is generated in code in this repository. +

+
+
+ ) +} diff --git a/src/game/concede.test.ts b/src/game/concede.test.ts new file mode 100644 index 0000000..0aae318 --- /dev/null +++ b/src/game/concede.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { IN_THE_HUNT, carried, onConcession, onDraw } from './concede' +import type { Power } from './map' +import type { Ownership } from './turn' + +/** A board described by how many centres each power holds. */ +const board = (counts: Partial>): Ownership => { + const own: Ownership = new Map() + let n = 0 + for (const [power, count] of Object.entries(counts)) { + for (let i = 0; i < count!; i++) own.set(`x${n++}`, power as Power) + } + return own +} + +describe('a draw', () => { + it('is refused by the power in front', () => { + const own = board({ russia: 12, france: 6, italy: 4 }) + expect(onDraw(own, 'russia').agree).toBe(false) + }) + + it('is refused by anybody still within reach of the front', () => { + const own = board({ russia: 12, france: 12 - IN_THE_HUNT }) + expect(onDraw(own, 'france').agree).toBe(false) + }) + + it('is accepted by anybody who has been left behind', () => { + const own = board({ russia: 12, france: 12 - IN_THE_HUNT - 1 }) + expect(onDraw(own, 'france').agree).toBe(true) + }) + + it('is accepted by a power with nothing left', () => { + const own = board({ russia: 12, france: 6 }) + expect(onDraw(own, 'italy').agree).toBe(true) + }) + + it('needs everybody, so one refusal is enough', () => { + const own = board({ russia: 12, france: 6, italy: 4 }) + const votes = (['russia', 'france', 'italy'] as Power[]).map((p) => onDraw(own, p)) + expect(votes.filter((v) => v.agree)).toHaveLength(2) + expect(carried(votes)).toBe(false) + }) + + it('carries when the board has no contender in it', () => { + const own = board({ russia: 9, france: 5, italy: 5, turkey: 5 }) + const votes = (['france', 'italy', 'turkey'] as Power[]).map((p) => onDraw(own, p)) + expect(carried(votes)).toBe(true) + }) + + it('says why, either way', () => { + const own = board({ russia: 12, france: 4 }) + expect(onDraw(own, 'russia').why).toMatch(/solo/) + expect(onDraw(own, 'france').why).toMatch(/share/) + }) +}) + +describe('a concession', () => { + it('is refused while the leader is nowhere near eighteen', () => { + const own = board({ russia: 12, france: 6 }) + expect(onConcession(own, 'france', 'russia').agree).toBe(false) + }) + + it('is refused by somebody close enough to stop it', () => { + const own = board({ russia: 15, france: 14 }) + expect(onConcession(own, 'france', 'russia').agree).toBe(false) + }) + + it('is accepted when the leader is nearly there and nobody can reach them', () => { + const own = board({ russia: 16, france: 6, italy: 5 }) + expect(onConcession(own, 'france', 'russia').agree).toBe(true) + expect(onConcession(own, 'italy', 'russia').agree).toBe(true) + }) + + it('is harder to get than a draw on the same board', () => { + // The point of having both: a board where everybody will end it level is + // usually a board where nobody will hand it over. + const own = board({ russia: 13, france: 5, italy: 5 }) + const draw = (['france', 'italy'] as Power[]).map((p) => onDraw(own, p)) + const give = (['france', 'italy'] as Power[]).map((p) => onConcession(own, p, 'russia')) + expect(carried(draw)).toBe(true) + expect(carried(give)).toBe(false) + }) +}) diff --git a/src/game/concede.ts b/src/game/concede.ts new file mode 100644 index 0000000..d7e9c5c --- /dev/null +++ b/src/game/concede.ts @@ -0,0 +1,114 @@ +import { POWERS, SOLO, type Power } from './map' +import { centreCount, type Ownership } from './turn' + +/** + * Giving up, and the two different things people mean by it. + * + * A game of Diplomacy that is lost can take another five years to finish + * losing, and every online version of the game has had to answer this. The + * hobby settled on two answers and they are not the same: + * + * - **Resigning** is unilateral. You stop playing your power and nobody + * gets a say, because nobody is entitled to your attention. The power + * itself does not leave the board -- it goes into civil disorder, holds + * everything and pays units off as it loses centres, which is precisely + * what the rules already say happens to an absent player. That matters + * to everyone else: a resigned Austria is still eleven centres somebody + * has to take, and letting it evaporate would hand the game to whoever + * happened to be next to it. + * + * - **A draw** is not unilateral, and this is the part that makes it + * Diplomacy rather than a menu. Every surviving power votes, and one + * refusal is enough. A power that still believes it can win will refuse, + * and it should -- being able to end the game by asking would be worth + * more than any alliance in it. + * + * The votes are decided on the position, not on politeness, and each comes + * back with its reason so a refusal can be argued with next year. + */ + +export interface Verdict { + power: Power + agree: boolean + why: string +} + +/** Within this of the lead and a power still fancies its chances. */ +export const IN_THE_HUNT = 2 + +const name = (p: Power) => p[0]!.toUpperCase() + p.slice(1) + +/** The largest centre count on the board. */ +export const lead = (own: Ownership): number => + Math.max(...POWERS.map((p) => centreCount(own, p))) + +/** + * Would this power accept a draw? + * + * It refuses while it leads or is within a couple of centres of leading, + * because that power is still playing to win and a draw would take that + * away. Everybody else has more to gain from ending level than from being + * ground down over another eight years, and says so. + */ +export function onDraw(own: Ownership, power: Power): Verdict { + const mine = centreCount(own, power) + const top = lead(own) + + if (mine === 0) { + return { power, agree: true, why: `${name(power)} has nothing left to draw with.` } + } + if (mine >= top) { + return { power, agree: false, why: `${name(power)} leads with ${mine} and wants the solo.` } + } + if (top - mine <= IN_THE_HUNT) { + return { + power, + agree: false, + why: `${name(power)} is ${top - mine} behind and thinks that is catchable.`, + } + } + return { + power, + agree: true, + why: `${name(power)} is ${top - mine} behind and would rather share it.`, + } +} + +/** + * Would this power let the leader simply be given the game? + * + * Far harder than a draw, and it should be. A concession hands somebody the + * solo, so a power only agrees when that solo was coming anyway -- and the + * measure of "anyway" is that the leader is most of the way to eighteen and + * further ahead than anyone can close. Otherwise refusing costs nothing but + * time, and time is the only thing the losing side has. + */ +export function onConcession(own: Ownership, power: Power, to: Power): Verdict { + const theirs = centreCount(own, to) + const mine = centreCount(own, power) + + if (power === to) { + return { power, agree: true, why: `${name(power)} will take it.` } + } + if (theirs < SOLO - 4) { + return { + power, + agree: false, + why: `${name(power)} says ${theirs} is not eighteen yet.`, + } + } + if (mine === 0) { + return { power, agree: true, why: `${name(power)} has no say left.` } + } + if (theirs - mine <= IN_THE_HUNT) { + return { + power, + agree: false, + why: `${name(power)} is close enough to stop it.`, + } + } + return { power, agree: true, why: `${name(power)} cannot stop it and says so.` } +} + +/** A vote is carried only if nobody still standing refuses. */ +export const carried = (verdicts: readonly Verdict[]): boolean => verdicts.every((v) => v.agree) diff --git a/src/game/game.test.ts b/src/game/game.test.ts index 861df64..6a52bb1 100644 --- a/src/game/game.test.ts +++ b/src/game/game.test.ts @@ -1,14 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - botOrders, - negotiate, - newGame, - resolveBuilds, - resolveOrders, - resolveRetreats, - turnOf, - type Game, -} from './game' +import { askForDraw, askToConcede, botOrders, negotiate, newGame, resign, resolveBuilds, resolveOrders, resolveRetreats, turnOf, type Game } from './game' import { POWERS, type Power } from './map' import { centreCount, unitCount } from './turn' import type { Order } from './orders' @@ -212,3 +203,64 @@ describe('the press, over a turn', () => { expect(look(after.ledger, 'russia', 'austria').broken).toBe(0) }) }) + +describe('giving up', () => { + it('leaves the units on the board, holding', () => { + // The whole point. A resigned Austria is still centres somebody has to + // go and take; letting them evaporate would hand the game to whoever + // happened to be next door. + const before = newGame() + const after = resign(before, 'austria') + expect(after.board.size).toBe(before.board.size) + expect(after.resigned).toContain('austria') + + const played = resolveOrders(after, 'england', []) + for (const [at, unit] of before.board) { + if (unit.power !== 'austria') continue + expect(played.board.get(at)?.power, `${at} should not have moved`).toBe('austria') + } + }) + + it('cannot be done twice, or after the game is over', () => { + const once = resign(newGame(), 'austria') + expect(resign(once, 'austria').resigned).toHaveLength(1) + expect(resign({ ...once, phase: 'over' }, 'italy').resigned).toHaveLength(1) + }) + + it('is refused a draw by a power that is still winning', () => { + const g = newGame() + const own = new Map(g.own) + for (const p of ['bud', 'tri', 'ser', 'gre', 'rum', 'bul', 'ven', 'mun']) { + own.set(p, 'austria') + } + const { game, verdicts } = askForDraw({ ...g, own }, 'england') + expect(verdicts.find((v) => v.power === 'austria')?.agree).toBe(false) + expect(game.phase).toBe('orders') + }) + + it('is refused by everybody at the opening, which is the right answer', () => { + // Three centres each and Russia on four. Nobody is out of it in 1901, + // so nobody agrees to end it, and a game that could be drawn on the + // first turn would not be worth playing. + const { game, verdicts } = askForDraw(newGame(), 'austria') + expect(verdicts.every((v) => !v.agree)).toBe(true) + expect(game.phase).toBe('orders') + }) + + it('is granted once the board has left everybody else behind', () => { + const g = newGame() + const own = new Map(g.own) + // Austria on nine; the rest on three or four and out of reach. + for (const p of ['ser', 'gre', 'rum', 'bul', 'ven', 'mun']) own.set(p, 'austria') + const { game, verdicts } = askForDraw({ ...g, own }, 'austria') + expect(verdicts.every((v) => v.agree)).toBe(true) + expect(game.phase).toBe('over') + expect(game.drawn.length).toBeGreaterThan(1) + }) + + it('will not hand the game to somebody who has not won it', () => { + const { game } = askToConcede(newGame(), 'austria', 'russia') + expect(game.phase).toBe('orders') + expect(game.winner).toBeNull() + }) +}) diff --git a/src/game/game.ts b/src/game/game.ts index 96d7c1f..c369791 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -26,6 +26,7 @@ import { type Ownership, type RetreatOrder, } from './turn' +import { carried, onConcession, onDraw, type Verdict } from './concede' import { buildsSounded, movesSounded, retreatsSounded, type Cue } from './sound' /** @@ -86,6 +87,17 @@ export interface Game { drawn: Power[] /** Powers with no centres left. They stay on the list, at nothing. */ out: Power[] + /** + * Powers that gave up. + * + * They do not leave the board. A resigned power goes into civil disorder: + * it holds everything, waives its builds and pays units off as it loses + * centres, which is what the rules already say happens to an absent + * player. That matters to everybody else -- a resigned Austria is still + * eleven centres somebody has to go and take, and letting it evaporate + * would hand the game to whoever happened to be next to it. + */ + resigned: Power[] } /** Turns are counted from the opening, so a deal can name one. */ @@ -111,11 +123,84 @@ export function newGame(): Game { winner: null, drawn: [], out: [], + resigned: [], } } const alive = (g: Game): Power[] => POWERS.filter((p) => !g.out.includes(p)) +/** Still playing: on the board, and still interested. */ +const playing = (g: Game): Power[] => alive(g).filter((p) => !g.resigned.includes(p)) + +/** + * A power gives up. + * + * Unilateral, because nobody is entitled to anybody's attention. The board + * keeps the units: from here they hold, build nothing, and are paid off as + * the centres go. See `concede.ts`. + */ +export function resign(g: Game, power: Power): Game { + if (g.resigned.includes(power) || g.out.includes(power) || g.phase === 'over') return g + return { + ...g, + resigned: [...g.resigned, power], + log: [ + ...g.log, + `${power[0]!.toUpperCase()}${power.slice(1)} gives up. Its units hold where they stand.`, + ], + } +} + +/** + * A draw, put to the surviving powers. + * + * One refusal is enough, which is the whole point: being able to end the + * game by asking would be worth more than any alliance in it. + */ +export function askForDraw(g: Game, asking: Power): { game: Game; verdicts: Verdict[] } { + const verdicts = alive(g) + .filter((p) => p !== asking && !g.resigned.includes(p)) + .map((p) => onDraw(g.own, p)) + if (!carried(verdicts)) { + const no = verdicts.filter((v) => !v.agree) + return { game: { ...g, log: [...g.log, ...no.map((v) => v.why)] }, verdicts } + } + const drawn = alive(g) + return { + game: { + ...g, + phase: 'over', + drawn, + log: [...g.log, `A draw is agreed between ${drawn.join(', ')}.`], + }, + verdicts, + } +} + +/** The same question about handing the game to whoever is winning. */ +export function askToConcede( + g: Game, + asking: Power, + to: Power, +): { game: Game; verdicts: Verdict[] } { + const verdicts = alive(g) + .filter((p) => p !== asking && !g.resigned.includes(p)) + .map((p) => onConcession(g.own, p, to)) + if (!carried(verdicts)) { + const no = verdicts.filter((v) => !v.agree) + return { game: { ...g, log: [...g.log, ...no.map((v) => v.why)] }, verdicts } + } + return { + game: { + ...g, + phase: 'over', + winner: to, + log: [...g.log, `${to[0]!.toUpperCase()}${to.slice(1)} is conceded the game.`], + }, + verdicts, + } +} + /** * The talking, before the orders. * @@ -138,12 +223,14 @@ export function negotiate(g: Game, player: Power): { game: Game; asked: Overture const agreements: Agreement[] = [] const asked: Overture[] = [] - for (const from of alive(g)) { + // Nobody makes approaches to a power that has stopped answering, and a + // power that has given up makes none. + for (const from of playing(g)) { if (from === player) continue const mind: Mind = { power: from, ledger: g.ledger, agreements } for (const overture of propose(pos, mind, turn)) { const to = overture.proposal.to - if (g.out.includes(to)) continue + if (g.out.includes(to) || g.resigned.includes(to)) continue if (to === player) { asked.push(overture) continue @@ -161,7 +248,7 @@ export function negotiate(g: Game, player: Power): { game: Game; asked: Overture /** What each computer power intends this turn. */ export function botOrders(g: Game, player: Power): Map { const out = new Map() - for (const power of alive(g)) { + for (const power of playing(g)) { if (power === player) continue const mind: Mind = { power, ledger: g.ledger, agreements: g.agreements } out.set(power, chooseOrders({ board: g.board, own: g.own }, mind, turnOf(g)).orders) @@ -336,6 +423,11 @@ export function resolveBuilds( continue } + // A power that has given up waives everything it is owed. It still pays + // off what it can no longer keep, below: civil disorder is not a way to + // hold a board you have stopped defending. + if (owed > 0 && g.resigned.includes(power)) continue + if (owed > 0) { // Build at home, wherever there is room. A power that cannot use all // its builds simply waives the rest, which the rules allow.