A page before the map, and two ways to stop playing

Diplomacy has a reputation and about half of it is wrong, so the landing
page states the rules completely -- they are short enough -- and says the
strange part out loud: there is no luck in this game, 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 on starting are the only joke in here. There are none in this game;
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 concede.ts keeps them apart.

Resigning is unilateral, because nobody is entitled to your attention. But
the 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
everyone 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. Verified by playing four years past a resignation: Austria
sits on its three while France reaches six.

A draw is not unilateral, and that is what keeps it Diplomacy rather than a
menu. Every surviving power votes, one refusal is enough, and each refusal
comes back with its reason so it can be argued with next year. A power
refuses while it leads or is within two of leading. Asked in Spring 1901 all
six refuse -- the right answer, since a game that could be drawn on the
first turn would not be worth playing.

Conceding is harder to get than a draw and should be: it carries only when
the solo was coming anyway. Otherwise refusing costs nothing but time, and
time is all the losing side has.

The resign button asks twice, in the page rather than in a browser dialog.
It is the one control here that cannot be taken back.
This commit is contained in:
2026-09-09 09:47:49 -07:00
parent 8c31ecb35c
commit 5b402f2ce9
9 changed files with 803 additions and 26 deletions
+115
View File
@@ -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; }
+120 -13
View File
@@ -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<Map<string, RetreatOrder>>(new Map())
const [builds, setBuilds] = useState<AdjustOrder[]>([])
const [sound, setSound] = useState(true)
const [started, setStarted] = useState(false)
const [said, setSaid] = useState<string[]>([])
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<string>('')
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 (
<Landing
power={power}
onPick={(p) => {
wake()
if (synth.sfxOn) synth.tap()
setPower(p)
}}
onStart={() => {
wake()
if (synth.sfxOn) synth.dice()
setStarted(true)
}}
/>
)
}
return (
<div className="app">
@@ -343,7 +411,9 @@ export default function App() {
</select>
)}
{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.'}
</p>
@@ -373,7 +443,7 @@ export default function App() {
))}
</ul>
{game.phase === 'retreats' && game.outcome && (
{game.phase === 'retreats' && game.outcome && !quit && (
<RetreatPanel
power={power}
board={game.board}
@@ -384,7 +454,7 @@ export default function App() {
/>
)}
{game.phase === 'builds' && (
{game.phase === 'builds' && !quit && (
<BuildPanel
power={power}
board={game.board}
@@ -396,7 +466,7 @@ export default function App() {
/>
)}
{game.phase === 'orders' && (
{game.phase === 'orders' && !quit && (
<PressPanel
power={power}
turn={turnOf(game)}
@@ -409,7 +479,44 @@ export default function App() {
/>
)}
{game.phase === 'orders' && (
{quit && game.phase !== 'over' && (
<button type="button" className="submit watch" onClick={submit}>
Next turn
</button>
)}
{game.phase !== 'over' && (
<div className="giving-up">
{quit ? (
<p className="dim">
{game.out.includes(power)
? 'You have no centres left. The rest plays out without you.'
: 'You have resigned. Your units hold where they stand.'}
</p>
) : (
<>
<button type="button" onClick={askDraw}>
Ask for a draw
</button>
<button type="button" onClick={askConcede}>
Offer the game
</button>
<button type="button" className="resign" onClick={giveUp}>
{sure ? 'Resign — certain?' : 'Resign'}
</button>
</>
)}
{said.length > 0 && (
<ul className="said">
{said.map((why, i) => (
<li key={i}>{why}</li>
))}
</ul>
)}
</div>
)}
{game.phase === 'orders' && !quit && (
<OrderPanel
units={units}
orders={orders}
+40
View File
@@ -371,6 +371,46 @@ export class Synth {
}
}
/**
* Dice on a table, for the start of a game.
*
* There are no dice in Diplomacy and that is the joke: this is the sound
* of every other board game you have played, and the last one you will
* hear before finding out that nothing here is luck.
*
* Two of them, tumbling. A die is a handful of short bright knocks at
* uneven spacing, slowing as it loses energy, and then it stops -- so the
* gaps grow and the knocks quieten, which between them is the whole
* illusion.
*/
dice() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [die, lead] of [0, 0.07].entries()) {
let t = at + lead
let gap = 0.045
for (let i = 0; i < 11; i++) {
const knock = ctx.createBufferSource()
knock.buffer = this.noiseBuffer
knock.playbackRate.value = 1.4 + die * 0.2
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 1500 + ((i * 7 + die * 11) % 5) * 260
bp.Q.value = 3
const env = ctx.createGain()
const gain = 0.11 * (1 - i / 12)
env.gain.setValueAtTime(gain, t)
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.03)
knock.connect(bp).connect(env).connect(this.sfxBus)
knock.start(t)
knock.stop(t + 0.04)
t += gap
gap *= 1.16
}
}
}
// -------------------------------------------------------------- the war
/**
+129
View File
@@ -0,0 +1,129 @@
import { COLOURS, POWER_NAMES } from './Board'
import { LAST_YEAR } from '../game/game'
import { POWERS, SOLO, type Power } from '../game/map'
/**
* The page before the map.
*
* Diplomacy has a reputation and about half of it is wrong, so it is worth
* two paragraphs before anybody clicks. 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 the same strength, and the only
* thing that ever decides anything is who agreed to help.
*
* It is also the honest place for the licence. A page that says nothing
* about where the map came from is a page quietly hoping nobody asks.
*/
export function Landing({
power,
onPick,
onStart,
}: {
power: Power
onPick: (p: Power) => void
onStart: () => void
}) {
return (
<div className="landing">
<header>
<h1>Great Powers</h1>
<p className="tagline">
Europe, 1901. Seven of you, thirty-four supply centres, and no dice.
</p>
</header>
<div className="cols">
<section>
<h2>The whole game</h2>
<p>
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.
</p>
<p>
<strong>Every unit is exactly as strong as every other.</strong> 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.
</p>
<p>
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.
</p>
<p>
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.
</p>
</section>
<section>
<h2>Winning, and stopping</h2>
<p>
<strong>{SOLO} of the 34 centres wins outright.</strong> 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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<h2>Talking</h2>
<p>
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.
</p>
</section>
</div>
<div className="choose">
<h2>Take a power</h2>
<ul>
{POWERS.map((p) => (
<li key={p}>
<button
type="button"
className={p === power ? 'on' : ''}
aria-pressed={p === power}
onClick={() => onPick(p)}
>
<span className="swatch" style={{ background: COLOURS[p] }} />
{POWER_NAMES[p]}
</button>
</li>
))}
</ul>
<button type="button" className="start" onClick={onStart}>
Spring 1901
</button>
</div>
<footer>
<p>
Great Powers is free software under the{' '}
<a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU Affero General Public License,
version 3 or later</a>. 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.
</p>
<p>
The map is derived from the standard map of the{' '}
<a href="https://github.com/diplomacy/diplomacy">diplomacy</a> project, also AGPL, and
the adjudicator is tested against Lucas Kruijswijk's Diplomacy Adjudicator Test Cases.
Neither is affiliated with this. See NOTICE.md.
</p>
<p>
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.
</p>
</footer>
</div>
)
}
+83
View File
@@ -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<Record<Power, number>>): 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)
})
})
+114
View File
@@ -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)
+62 -10
View File
@@ -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()
})
})
+95 -3
View File
@@ -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<Power, Order[]> {
const out = new Map<Power, Order[]>()
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.