World Conquest: the classic game of world domination, one against five

Forty-two territories grouped from Natural Earth's public-domain outlines,
the classic eighty-three links, exact dice odds, escalating card sets, and
five bots that play every seeded game through to a winner.
This commit is contained in:
2026-09-17 23:13:56 -07:00
commit 49feff0e58
39 changed files with 8141 additions and 0 deletions
+337
View File
@@ -0,0 +1,337 @@
/* =====================================================================
World Conquest.
The site's cel look: flat fills, one heavy outline on everything, no
gradients anywhere near the board. And, like the seven-power game, it
takes the whole window -- forty-two territories do not fit a cabinet.
===================================================================== */
.app {
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
grid-template-rows: auto minmax(0, 1fr);
grid-template-areas: 'head head' 'map side';
gap: 10px 18px;
padding: clamp(10px, 2vw, 22px);
}
header {
grid-area: head;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 6px 12px;
}
header .ways {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: baseline;
gap: 6px 10px;
font-size: 0.85rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(1.4rem, 3.2vw, 2.2rem);
font-weight: 900;
letter-spacing: -0.02em;
color: #ffd479;
-webkit-text-stroke: 3px #241a10;
paint-order: stroke fill;
}
button {
font: inherit;
font-weight: 700;
font-size: 0.85rem;
color: #f2e9d8;
background: #22304a;
border: 2px solid #2b2318;
border-radius: 7px;
padding: 4px 11px;
cursor: pointer;
}
button:disabled { opacity: 0.4; cursor: default; }
button:focus-visible { outline: 3px solid #ffd479; outline-offset: 1px; }
button.go {
color: #241a10;
background: #ffd479;
font-weight: 800;
}
button.quiet { background: none; color: #a3b3c9; border-color: #2f3b52; }
.sound[aria-pressed='false'] { color: #8c98ab; background: #1b2437; }
/* Red, because this one cannot be taken back. */
.leave { color: #ffb3a7; background: #2c3b57; }
header select {
font: inherit;
font-weight: 700;
color: #f2e9d8;
background: #22304a;
border: 2px solid #2b2318;
border-radius: 6px;
padding: 1px 6px;
}
.dim { color: #a3b3c9; }
.small { font-size: 0.78rem; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
/* ------------------------------------------------------------ the map */
.map-wrap {
grid-area: map;
position: relative;
min-width: 0;
min-height: 0;
display: flex;
align-items: flex-start;
justify-content: center;
}
.board {
/* Sized by its width and the map's own proportions, never letterboxed:
empty ocean above and below the world is not a place anybody can go. */
width: 100%;
height: auto;
max-height: 100%;
border: 2px solid #2b2318;
border-radius: 8px;
background: #cfe3ef;
user-select: none;
}
.ocean { fill: #cfe3ef; }
/* Each continent's shore in its own color, drawn under the land so only the
coast shows. */
.shores path {
fill: none;
stroke-width: 7;
stroke-linejoin: round;
stroke-opacity: 0.85;
}
.lanes line {
stroke: #41596b;
stroke-width: 2;
stroke-dasharray: 6 5;
stroke-linecap: round;
}
.land {
stroke: #2b2318;
stroke-width: 0.9;
stroke-linejoin: round;
cursor: pointer;
transition: filter 0.2s;
}
.land:hover { filter: brightness(1.12); }
.land.picked { stroke: #fffaf0; stroke-width: 3.5; filter: brightness(1.18); }
.land.open { stroke: #241a10; stroke-width: 2.6; stroke-dasharray: 5 4; }
.land.aimed { stroke: #fffaf0; stroke-width: 3.5; stroke-dasharray: none; }
@keyframes taken {
from { filter: brightness(1.8) saturate(0.4); }
to { filter: none; }
}
.land.flash { animation: taken 0.9s ease-out; }
.borders path { fill: none; pointer-events: none; stroke-linejoin: round; stroke-linecap: round; }
.borders .inner { stroke: none; }
.borders .continental { stroke: #241a10; stroke-width: 2.4; }
/* A fence: the real map touches here and the rules do not cross. */
.borders .closed { stroke: #241a10; stroke-width: 4.5; stroke-dasharray: 1 5; }
.badges g { cursor: pointer; }
.badges circle { stroke: #fffaf0; stroke-width: 1.6; }
.badges text {
fill: #fffaf0;
font-size: 11px;
font-weight: 900;
text-anchor: middle;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
/* ------------------------------------------------------------ the end */
.ending {
position: absolute;
inset: auto 50% 50% auto;
transform: translate(50%, 50%);
max-width: min(440px, 90%);
padding: 18px 20px;
border: 2px solid #2b2318;
border-radius: 10px;
background: #1b2437;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45);
}
.ending h2 { margin: 0 0 6px; color: #ffd479; font-size: 1.3rem; }
.ending p { margin: 0 0 12px; color: #cbd6e6; line-height: 1.45; }
/* ------------------------------------------------------------ the side */
.side {
grid-area: side;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
padding-right: 4px;
font-size: 0.86rem;
}
.side section { display: flex; flex-direction: column; gap: 6px; }
.side p { margin: 0; line-height: 1.45; }
.side h2 { margin: 0; font-size: 0.95rem; color: #ffd479; }
.side strong { color: #fffaf0; }
.side section > button { align-self: flex-start; }
.now {
padding: 10px;
border: 2px solid #2b2318;
border-radius: 8px;
background: #22304a;
}
.turn { font-weight: 800; font-size: 0.95rem; display: flex; align-items: center; }
.toggle[aria-pressed='true'] { background: #3c527a; box-shadow: inset 0 0 0 2px #ffd479; }
.odds { color: #ffd479; font-weight: 700; }
input[type='range'] { width: 100%; accent-color: #ffd479; }
.chip {
display: inline-block;
width: 11px;
height: 11px;
border: 2px solid #241a10;
border-radius: 3px;
margin-right: 6px;
vertical-align: -1px;
flex: none;
}
.throw .dice { display: flex; align-items: center; gap: 10px; }
.throw .dice div { display: flex; gap: 4px; }
.throw .vs { color: #a3b3c9; font-size: 0.75rem; }
.die { width: 26px; height: 26px; }
.die rect { stroke: #241a10; stroke-width: 0.25; }
.die.attack rect { fill: #c8452f; }
.die.attack circle { fill: #fffaf0; }
.die.defend rect { fill: #fffaf0; }
.die.defend circle { fill: #241a10; }
.cards { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 5px; }
.card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
min-width: 80px;
padding: 5px 8px;
font-size: 0.76rem;
text-align: left;
background: #2a3a58;
}
.card:disabled { opacity: 1; cursor: default; }
.card[aria-pressed='true'] { box-shadow: inset 0 0 0 2px #ffd479; background: #3c527a; }
.card .glyph { font-size: 0.95rem; color: #ffd479; }
.card.wild .glyph { color: #ff9a8b; }
.card .own { font-size: 0.66rem; color: #9fe0b9; }
.table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
.table th, .table td { padding: 2px 4px; text-align: right; }
.table thead th { font-size: 0.72rem; color: #a3b3c9; font-weight: 700; }
.table tbody th { text-align: left; white-space: nowrap; }
.table tr.now th { color: #ffd479; }
.table tr.gone { opacity: 0.4; text-decoration: line-through; }
.continents { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.continents li { display: flex; align-items: center; gap: 6px; }
.continents .shore { width: 16px; height: 0; border-top: 5px solid; border-radius: 3px; }
.continents .held { margin-left: auto; display: flex; align-items: center; font-weight: 700; }
.log ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.log li { font-size: 0.78rem; line-height: 1.35; color: #cbd6e6; }
.log li.heading { margin-top: 5px; color: #ffd479; font-weight: 700; }
@media (max-width: 860px) {
.app {
height: auto;
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
grid-template-areas: 'head' 'map' 'side';
}
.side { overflow: visible; }
}
/* ---------------------------------------------------------- the landing */
.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 ol { margin: 0 0 10px; padding-left: 1.3em; }
.landing ol li { margin-bottom: 6px; }
.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 li button { display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 0.95rem; }
.landing .choose button.on { background: #3c527a; box-shadow: inset 0 0 0 2px #ffd479; }
.swatch { width: 15px; height: 15px; border: 2px solid #241a10; border-radius: 4px; }
.landing .choose .start {
font-size: 1.05rem;
font-weight: 800;
color: #241a10;
background: #ffd479;
padding: 10px 26px;
}
.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; }
.landing footer .ways { font-size: 0.95rem; color: #cbd6e6; margin-bottom: 12px; }
+402
View File
@@ -0,0 +1,402 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import './App.css'
import { playFor } from './audio/play'
import { MARCH } from './audio/score'
import { synth } from './audio/synth'
import { Board } from './components/Board'
import { Landing } from './components/Landing'
import { Side } from './components/Side'
import { apply, decide, leanings, type Action, type Leaning } from './game/bot'
import { setsIn } from './game/cards'
import { PLAYER_NAMES } from './game/describe'
import { Game, PLAYER_COUNT } from './game/game'
import type { Territory } from './game/map'
export type Speed = 'steady' | 'quick' | 'instant'
/**
* How long a bot takes over each kind of move, in milliseconds, at steady.
*
* Slow enough to follow, which is the whole reason to pace it at all: a bot
* that finishes its turn before you have looked up has told you nothing
* about what it did. The dice get the least, because there are the most of
* them, and a trade the most, because it is the one move everybody at a
* table stops to watch.
*/
const PACE: Record<Action['type'], number> = {
place: 320,
trade: 900,
beginAttack: 250,
attack: 380,
occupy: 320,
endAttack: 200,
fortify: 600,
endTurn: 350,
}
const pace = (a: Action, speed: Speed, setup: boolean) =>
setup ? (speed === 'steady' ? 70 : 25) : PACE[a.type] * (speed === 'steady' ? 1 : 0.35)
export default function App() {
const [screen, setScreen] = useState<'landing' | 'game'>('landing')
const [color, setColor] = useState(0)
const [, setVersion] = useState(0)
const bump = useCallback(() => setVersion((v) => v + 1), [])
const gameRef = useRef<Game | null>(null)
const leanRef = useRef<Leaning[]>([])
const seen = useRef(0)
const [speed, setSpeed] = useState<Speed>('steady')
const [music, setMusic] = useState(true)
const [sfx, setSfx] = useState(true)
const [sel, setSel] = useState<Territory | null>(null)
const [target, setTarget] = useState<Territory | null>(null)
const [flash, setFlash] = useState<ReadonlySet<Territory>>(new Set())
const [autoSetup, setAutoSetup] = useState(false)
const [watching, setWatching] = useState(false)
const [leaving, setLeaving] = useState(false)
/** How many armies a click puts down while reinforcing. */
const [step, setStep] = useState<1 | 5 | 'all'>(1)
const g = gameRef.current
/** Everything that happens goes through here: sound, the flash, and a redraw. */
const settle = useCallback(() => {
const game = gameRef.current
if (!game) return
const fresh = game.events.slice(seen.current)
seen.current = game.events.length
const taken = new Set<Territory>()
for (const e of fresh) if (e.kind === 'conquer') taken.add(e.to)
if (taken.size) setFlash(taken)
playFor(fresh, color)
if (game.phase === 'over') synth.stopTune()
bump()
}, [bump, color])
// ---------------------------------------------------------- the bots
const humanOut = g ? !g.players[color].alive : false
useEffect(() => {
const game = gameRef.current
if (!game || screen !== 'game' || game.phase === 'over') return
const human = game.players[game.current].human
const autopilot = human && game.phase === 'setup' && autoSetup
if (human && !autopilot) return
if (humanOut && !watching) return
const act = () => {
const a = decide(game, leanRef.current[game.current])
apply(game, a)
return a
}
if (speed === 'instant' && !autopilot) {
// Straight through to the next thing that needs a person, with the
// screen drawn once at the end. There is a ceiling in case that never
// comes -- a whole game of bots is a few thousand moves.
const id = window.setTimeout(() => {
for (let i = 0; i < 20000; i++) {
if (game.phase === 'over' || game.players[game.current].human) break
act()
}
settle()
}, 60)
return () => window.clearTimeout(id)
}
const next = decide(game, leanRef.current[game.current])
const id = window.setTimeout(() => {
apply(game, next)
settle()
}, pace(next, speed, game.phase === 'setup'))
return () => window.clearTimeout(id)
})
// The flash fades on its own.
useEffect(() => {
if (flash.size === 0) return
const id = window.setTimeout(() => setFlash(new Set()), 900)
return () => window.clearTimeout(id)
}, [flash])
// ------------------------------------------------------------- music
useEffect(() => {
synth.setMusic(music)
}, [music])
useEffect(() => {
synth.setSfx(sfx)
}, [sfx])
// --------------------------------------------------------- starting
const start = () => {
synth.ensure()
const seed = (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0
const game = new Game(seed, color)
gameRef.current = game
leanRef.current = leanings(seed, PLAYER_COUNT)
seen.current = 0
setSel(null)
setTarget(null)
setAutoSetup(false)
setWatching(false)
setLeaving(false)
setScreen('game')
synth.dice(4)
if (music) synth.playTune(MARCH)
settle()
}
const leave = () => {
synth.stopTune()
gameRef.current = null
setScreen('landing')
}
// ------------------------------------------------ the player's moves
const mine = g ? g.current === color && g.phase !== 'over' : false
/** The one move a click on the map can mean, given the phase. */
const pick = (t: Territory) => {
if (!g || !mine) return
const own = g.owner[t] === color
switch (g.phase) {
case 'setup':
if (!own) return synth.reject()
g.place(t)
synth.tap()
return settle()
case 'reinforce':
if (!own || g.mustTrade || g.reinforcements === 0) return synth.reject()
return act.place(t, step === 'all' ? g.reinforcements : Math.min(step, g.reinforcements))
case 'attack':
if (own) {
if (t === sel) {
setSel(null)
setTarget(null)
} else if (g.canAttackFrom(t)) {
setSel(t)
setTarget(null)
synth.tap()
} else synth.reject()
return
}
if (sel && g.attackTargets(sel).includes(t)) {
setTarget(t)
synth.written()
} else synth.reject()
return
case 'fortify':
if (!own) return synth.reject()
if (sel && t !== sel && g.fortifyTargets(sel).includes(t)) {
setTarget(t)
synth.written()
} else if (t === sel) {
setSel(null)
setTarget(null)
} else if (g.fortifyTargets(t).length) {
setSel(t)
setTarget(null)
synth.tap()
} else synth.reject()
return
}
}
const act = {
place: (t: Territory, n: number) => {
if (!g) return
g.place(t, n)
synth.muster()
// Nothing left to place and no set worth trading: straight on.
if (g.reinforcements === 0 && setsIn(g.hand).length === 0) g.beginAttack()
settle()
},
trade: (cards: [number, number, number]) => {
g?.trade(cards)
settle()
},
beginAttack: () => {
g?.beginAttack()
settle()
},
roll: (dice: number) => {
if (!g || !sel || !target) return
g.attack(sel, target, dice)
afterRoll()
},
blitz: () => {
if (!g || !sel || !target) return
while (g.phase === 'attack' && g.armies[sel] > 1) g.attack(sel, target)
afterRoll()
},
occupy: (n: number) => {
if (!g || !g.occupation) return
const to = g.occupation.to
g.occupy(n)
// Stay on the new ground, ready to push on from it.
setSel(g.canAttackFrom(to) ? to : null)
setTarget(null)
settle()
},
endAttack: () => {
g?.endAttack()
setSel(null)
setTarget(null)
settle()
},
fortify: (n: number) => {
if (!g || !sel || !target) return
g.fortify(sel, target, n)
setSel(null)
setTarget(null)
settle()
},
endTurn: () => {
g?.endTurn()
setSel(null)
setTarget(null)
settle()
},
autoSetup: () => {
setAutoSetup(true)
},
}
const afterRoll = () => {
if (!g || !sel) return
if (g.phase === 'attack' && !g.canAttackFrom(sel)) {
setSel(null)
setTarget(null)
}
settle()
}
// Escape lets go of whatever is picked.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSel(null)
setTarget(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const targets: Territory[] =
!g || !sel || !mine
? []
: g.phase === 'attack'
? g.attackTargets(sel)
: g.phase === 'fortify'
? g.fortifyTargets(sel)
: []
if (screen === 'landing' || !g) {
return <Landing color={color} onPick={setColor} onStart={start} />
}
const ended = g.phase === 'over'
const won = ended && g.winner === color
return (
<div className="app">
<header>
<h1>World Conquest</h1>
<nav className="ways">
<label>
Bots{' '}
<select value={speed} onChange={(e) => setSpeed(e.target.value as Speed)}>
<option value="steady">steady</option>
<option value="quick">quick</option>
<option value="instant">instant</option>
</select>
</label>
<button
type="button"
className="sound"
aria-pressed={music}
onClick={() => {
const on = !music
setMusic(on)
if (on && !ended) synth.playTune(MARCH, false)
}}
>
Music
</button>
<button type="button" className="sound" aria-pressed={sfx} onClick={() => setSfx(!sfx)}>
Sound
</button>
{leaving ? (
<>
<button type="button" className="leave" onClick={leave}>
Yes, abandon it
</button>
<button type="button" className="sound" onClick={() => setLeaving(false)}>
Keep playing
</button>
</>
) : (
<button type="button" className="leave" onClick={() => (ended ? leave() : setLeaving(true))}>
New game
</button>
)}
</nav>
</header>
<main className="map-wrap">
<Board game={g} selected={sel} targets={targets} target={target} flash={flash} onPick={pick} />
{(ended || (humanOut && !watching)) && (
<div className="ending" role="dialog" aria-live="polite">
{ended ? (
<>
<h2>{won ? 'The world is yours.' : `${PLAYER_NAMES[g.winner!]} holds the world.`}</h2>
<p>
{won
? `Round ${g.round}. Forty-two territories, and nobody left to take them back.`
: humanOut
? 'You were out of it before the end, but the end came all the same.'
: `Round ${g.round}.`}
</p>
</>
) : (
<>
<h2>You are out.</h2>
<p>Your last territory has gone. The war goes on without you.</p>
</>
)}
<div className="row">
{!ended && (
<button type="button" onClick={() => setWatching(true)}>
Watch it finish
</button>
)}
<button type="button" className="go" onClick={leave}>
Another war
</button>
</div>
</div>
)}
</main>
<Side
game={g}
human={color}
sel={sel}
target={target}
autoSetup={autoSetup}
step={step}
onStep={setStep}
act={act}
/>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { GameEvent } from '../game/game'
import { synth } from './synth'
/**
* What a batch of events sounds like.
*
* At a steady pace a batch is one move and gets its sound. At speed it can
* be a whole round, and forty dice and six conquests at once are one
* indistinct crash -- so a batch makes at most one noise, the most important
* thing in it, in this order: the end, somebody going out, a trade, a
* territory taken, a throw of the dice.
*/
export function playFor(events: readonly GameEvent[], human: number) {
if (!synth.sfxOn || events.length === 0) return
const find = <K extends GameEvent['kind']>(kind: K) =>
events.findLast((e): e is Extract<GameEvent, { kind: K }> => e.kind === kind)
const win = find('win')
if (win) return win.player === human ? synth.victory() : synth.defeat()
const out = find('eliminate')
if (out) return out.loser === human ? synth.eliminated() : synth.fallen()
const trade = find('trade')
if (trade) return synth.fanfare(trade.armies)
if (find('conquer')) return synth.conquest()
const battle = find('battle')
if (battle) return synth.dice(battle.roll.attack.length + battle.roll.defend.length)
}
+98
View File
@@ -0,0 +1,98 @@
import type { Tune } from './synth'
/**
* The score: one march, and what it is.
*
* Not a quotation of anything. The idiom belongs to nobody -- a minor key, a
* dotted figure, brass moving over a band that holds its chords a bar at a
* time, and a side drum that never lets up -- and it is the same idiom the
* seven-power game uses, so the two sound like the same site without either
* borrowing a bar from the other.
*
* C minor, where that game is in D, and a little quicker: this is a game of
* dice and it moves faster than a game of letters. It loops for as long as
* the war does, and stops when somebody has the world.
*
* Eight bars. The first four climb the dotted figure twice and fall back;
* the second four answer them from a fourth higher and come home on the
* dominant borrowed from the harmonic minor -- the B natural in bar seven is
* the only note that is not in the key, and it is the one that makes the
* loop want to go round again.
*/
const bar = (...steps: string[]) => steps
const held = (chord: string) => [chord, '=', '=', '=', '=', '=', '=', '=']
export const MARCH: Tune = {
bpm: 108,
stepsPerBeat: 2,
tracks: [
{
wave: 'saw',
gain: 0.11,
gate: 0.92,
detune: 6,
filter: { from: 1700, to: 900, q: 1.3 },
notes: [
...bar('C4', '=', '=', 'D4', 'D#4', '=', '=', '='),
...bar('D4', '=', '=', 'D#4', 'F4', '=', '=', '='),
...bar('G4', '=', '=', '=', 'F4', '=', 'D#4', '='),
...bar('D4', '=', '=', '=', '=', '=', '.', '.'),
...bar('F4', '=', '=', 'G4', 'G#4', '=', '=', '='),
...bar('G4', '=', '=', 'F4', 'D#4', '=', '=', '='),
...bar('D4', '=', '=', '=', 'B3', '=', 'D4', '='),
...bar('C4', '=', '=', '=', '=', '=', '=', '.'),
],
},
{
wave: 'pulse50',
gain: 0.04,
gate: 1,
notes: [
...held('C3/D#3/G3'),
...held('G#2/C3/D#3'),
...held('C3/D#3/G3'),
...held('G2/B2/D3'),
...held('F2/G#2/C3'),
...held('C3/D#3/G3'),
...held('G2/B2/D3'),
...held('C3/D#3/G3'),
],
},
{
wave: 'triangle',
gain: 0.22,
gate: 1,
notes: [
...held('C2'),
...held('G#1'),
...held('C2'),
...held('G1'),
...held('F1'),
...held('C2'),
...held('G1'),
...held('C2'),
],
},
{
// The side drum, marking the end of each half rather than vamping.
wave: 'noise',
gain: 0.14,
notes: [
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', 'S', 'S', 'S', 'S', '.', 'S', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'),
],
},
{
wave: 'noise',
gain: 0.28,
notes: Array.from({ length: 64 }, (_, i) => (i % 4 === 0 ? 'K' : '.')),
},
],
}
+528
View File
@@ -0,0 +1,528 @@
/**
* The synth, shared with its siblings.
*
* The engine below -- the scheduler, the six voices and the small kit -- is
* the one written for the lemonade stand and carried through the cave game,
* the starship and the seven powers. Same author, same license, and
* deliberately not forked: a look-ahead scheduler is not the part of a game
* worth writing five times.
*
* What is written *for this game* is everything under "the war", and the
* score in `score.ts`. This is a game of dice, so the dice get the most care:
* a throw is heard as the number of dice actually thrown.
*/
const NOTE_INDEX: Record<string, number> = {
C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5,
'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11,
}
/** "C#4" -> Hz. A4 = 440. */
export function noteToFreq(note: string): number {
const m = /^([A-G]#?)(-?\d)$/.exec(note)
if (!m) return 0
const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12
return 440 * Math.pow(2, (semis - 69) / 12)
}
export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise'
export interface Track {
wave: Wave
gain: number
/**
* One entry per step. '.' rest, '=' sustain previous, otherwise a note.
* Slashes stack notes into a chord: "F4/A4/C5". On a noise track the
* letter picks the drum: K kick, S snare, C clap, H closed hat, O open hat.
*/
notes: string[]
/** Sweeping lowpass, the whole point of a funk bass. */
filter?: { from: number; to: number; q?: number }
/** Fraction of the note's length actually sounded; low values are stabs. */
gate?: number
/** Cents, for a fatter unison. */
detune?: number
}
export interface Tune {
bpm: number
stepsPerBeat: number
tracks: Track[]
/** 0 is straight, ~0.15 is a light funk shuffle. Delays every other step. */
swing?: number
}
/** Fourier series for a pulse wave of the given duty cycle. */
function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave {
const real = new Float32Array(harmonics + 1)
const imag = new Float32Array(harmonics + 1)
for (let n = 1; n <= harmonics; n++) {
imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty)
}
return ctx.createPeriodicWave(real, imag, { disableNormalization: false })
}
export class Synth {
private ctx: AudioContext | null = null
private master!: GainNode
private musicBus!: GainNode
private sfxBus!: GainNode
private waves: Partial<Record<Wave, PeriodicWave>> = {}
private noiseBuffer!: AudioBuffer
private tune: Tune | null = null
private step = 0
private nextStepTime = 0
private timer: number | null = null
musicOn = true
sfxOn = true
/** Must be called from a user gesture the first time. */
ensure(): AudioContext {
if (this.ctx) {
if (this.ctx.state === 'suspended') void this.ctx.resume()
return this.ctx
}
const ctx = new AudioContext()
this.ctx = ctx
this.master = ctx.createGain()
this.master.gain.value = 0.5
this.master.connect(ctx.destination)
/*
* The buses open at whatever the toggles already say, not at full.
* A one-shot calls `ensure` itself, so a game started with the sound
* turned off would otherwise make exactly one noise -- the first one --
* before anything got round to muting it.
*/
this.musicBus = ctx.createGain()
this.musicBus.gain.value = this.musicOn ? 0.55 : 0
this.musicBus.connect(this.master)
this.sfxBus = ctx.createGain()
this.sfxBus.gain.value = this.sfxOn ? 0.9 : 0
this.sfxBus.connect(this.master)
this.waves.pulse12 = pulseWave(ctx, 0.125)
this.waves.pulse25 = pulseWave(ctx, 0.25)
this.waves.pulse50 = pulseWave(ctx, 0.5)
const len = Math.floor(ctx.sampleRate * 1.5)
const buf = ctx.createBuffer(1, len, ctx.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
this.noiseBuffer = buf
return ctx
}
setMusic(on: boolean) {
this.musicOn = on
if (!this.ctx) return
this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05)
}
setSfx(on: boolean) {
this.sfxOn = on
if (!this.ctx) return
this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02)
}
// ---------------------------------------------------------------- voices
private voice(
dest: AudioNode,
wave: Wave,
freq: number,
at: number,
dur: number,
gain: number,
opts: { filter?: Track['filter']; detune?: number } = {},
) {
const ctx = this.ensure()
const osc = ctx.createOscillator()
if (wave === 'triangle') osc.type = 'triangle'
else if (wave === 'saw') osc.type = 'sawtooth'
else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!)
osc.frequency.setValueAtTime(freq, at)
if (opts.detune) osc.detune.setValueAtTime(opts.detune, at)
const env = ctx.createGain()
const peak = Math.max(0.0001, gain)
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(peak, at + 0.008)
env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6))
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
let node: AudioNode = osc
if (opts.filter) {
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.Q.value = opts.filter.q ?? 6
lp.frequency.setValueAtTime(opts.filter.from, at)
lp.frequency.exponentialRampToValueAtTime(
Math.max(60, opts.filter.to),
at + Math.max(0.05, dur),
)
osc.connect(lp)
node = lp
}
node.connect(env).connect(dest)
osc.start(at)
osc.stop(at + dur + 0.02)
}
/** A small kit: pitched-sine kick, noise-and-tone snare, clap, two hats. */
private drum(dest: AudioNode, kind: string, at: number, gain: number) {
const ctx = this.ensure()
if (kind === 'K') {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.setValueAtTime(130, at)
osc.frequency.exponentialRampToValueAtTime(42, at + 0.11)
const env = ctx.createGain()
env.gain.setValueAtTime(gain * 1.5, at)
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24)
osc.connect(env).connect(dest)
osc.start(at)
osc.stop(at + 0.26)
return
}
const noise = ctx.createBufferSource()
noise.buffer = this.noiseBuffer
const filter = ctx.createBiquadFilter()
const env = ctx.createGain()
let dur = 0.05
if (kind === 'S' || kind === 'C') {
filter.type = 'bandpass'
filter.frequency.value = kind === 'S' ? 1900 : 1300
filter.Q.value = kind === 'S' ? 0.9 : 2.4
dur = kind === 'S' ? 0.16 : 0.1
if (kind === 'S') {
// A little body under the crack.
const tone = ctx.createOscillator()
tone.type = 'triangle'
tone.frequency.setValueAtTime(210, at)
tone.frequency.exponentialRampToValueAtTime(150, at + 0.09)
const tenv = ctx.createGain()
tenv.gain.setValueAtTime(gain * 0.6, at)
tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.1)
tone.connect(tenv).connect(dest)
tone.start(at)
tone.stop(at + 0.12)
}
} else {
filter.type = 'highpass'
filter.frequency.value = 7200
dur = kind === 'O' ? 0.22 : 0.035
}
env.gain.setValueAtTime(gain, at)
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
noise.connect(filter).connect(env).connect(dest)
noise.start(at)
noise.stop(at + dur + 0.02)
}
// ------------------------------------------------------------- sequencer
playTune(tune: Tune, restart = true) {
this.ensure()
if (this.tune === tune && this.timer !== null && !restart) return
this.stopTune()
this.tune = tune
this.step = 0
this.nextStepTime = this.ctx!.currentTime + 0.08
this.timer = window.setInterval(() => this.schedule(), 25)
}
stopTune() {
if (this.timer !== null) window.clearInterval(this.timer)
this.timer = null
this.tune = null
}
get playing() {
return this.timer !== null
}
private schedule() {
const ctx = this.ctx
const tune = this.tune
if (!ctx || !tune) return
const stepDur = 60 / tune.bpm / tune.stepsPerBeat
const length = Math.max(...tune.tracks.map((t) => t.notes.length))
const swing = tune.swing ?? 0
while (this.nextStepTime < ctx.currentTime + 0.2) {
// A shuffle pushes every other step late without moving the downbeats.
const at = this.nextStepTime + (this.step % 2 === 1 ? swing * stepDur : 0)
for (const track of tune.tracks) {
const note = track.notes[this.step % track.notes.length]
if (!note || note === '.' || note === '=') continue
// A note runs until the next step that is not a sustain marker.
let held = 1
for (let i = 1; i < length; i++) {
if (track.notes[(this.step + i) % track.notes.length] === '=') held++
else break
}
const dur = held * stepDur * (track.gate ?? 0.95)
if (track.wave === 'noise') {
this.drum(this.musicBus, note, at, track.gain)
continue
}
for (const part of note.split('/')) {
const f = noteToFreq(part)
if (!f) continue
this.voice(this.musicBus, track.wave, f, at, dur, track.gain, {
filter: track.filter,
detune: track.detune,
})
}
}
this.nextStepTime += stepDur
this.step = (this.step + 1) % length
}
}
// ------------------------------------------------------------------ sfx
private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) {
const ctx = this.ensure()
let t = ctx.currentTime + 0.01
for (const [note, dur] of notes) {
if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain)
t += dur
}
}
/** A click on the map. Small, dry, and not a musical note. */
tap() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 2600
bp.Q.value = 1.4
const env = ctx.createGain()
env.gain.setValueAtTime(0.1, at)
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.035)
src.connect(bp).connect(env).connect(this.sfxBus)
src.start(at)
src.stop(at + 0.05)
}
/** A choice made: a territory picked, a number settled. */
written() {
this.seq([['B4', 0.045], ['E5', 0.08]], 'triangle', 0.14)
}
/** Something the rules will not take. */
reject() {
this.seq([['A3', 0.09], ['D#3', 0.18]], 'saw', 0.16)
}
// -------------------------------------------------------------- the war
/**
* Dice on a table.
*
* 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. As many as were
* thrown, up to five, each a hair behind the last so they do not merge.
*/
dice(count = 2) {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (let die = 0; die < Math.min(5, count); die++) {
let t = at + die * 0.035
let gap = 0.035
for (let i = 0; i < 8; i++) {
const knock = ctx.createBufferSource()
knock.buffer = this.noiseBuffer
knock.playbackRate.value = 1.3 + die * 0.15
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.1 * (1 - i / 9)
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.2
}
}
}
/**
* A field gun, and the shell landing.
*
* A crack with almost no body, then a lowpassed thump a beat later with
* plenty. Artillery at a distance is mostly the second one, so the first is
* kept quiet and quick -- turn it up and the whole thing becomes a drum.
*/
private gun(at: number, gain: number, pitch: number) {
const ctx = this.ensure()
const crack = ctx.createBufferSource()
crack.buffer = this.noiseBuffer
crack.playbackRate.value = 1.2
const hp = ctx.createBiquadFilter()
hp.type = 'highpass'
hp.frequency.value = 1100
const cenv = ctx.createGain()
cenv.gain.setValueAtTime(gain * 0.5, at)
cenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.09)
crack.connect(hp).connect(cenv).connect(this.sfxBus)
crack.start(at)
crack.stop(at + 0.11)
const body = ctx.createBufferSource()
body.buffer = this.noiseBuffer
body.playbackRate.value = 0.45
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.Q.value = 1.2
lp.frequency.setValueAtTime(900, at + 0.02)
lp.frequency.exponentialRampToValueAtTime(110, at + 0.5)
const benv = ctx.createGain()
benv.gain.setValueAtTime(0.0001, at + 0.02)
benv.gain.exponentialRampToValueAtTime(gain, at + 0.05)
benv.gain.exponentialRampToValueAtTime(0.0001, at + 0.55)
body.connect(lp).connect(benv).connect(this.sfxBus)
body.start(at + 0.02)
body.stop(at + 0.6)
const boom = ctx.createOscillator()
boom.type = 'sine'
boom.frequency.setValueAtTime(pitch, at + 0.03)
boom.frequency.exponentialRampToValueAtTime(pitch * 0.35, at + 0.4)
const oenv = ctx.createGain()
oenv.gain.setValueAtTime(gain * 0.8, at + 0.03)
oenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.45)
boom.connect(oenv).connect(this.sfxBus)
boom.start(at + 0.03)
boom.stop(at + 0.5)
}
/**
* A territory changing hands.
*
* Three guns and a bugle going up. The dice are the fight; this is the
* moment it is over, so it is shorter than a barrage and ends on the
* rising interval that has meant "forward" since there were bugles.
*/
conquest() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, offset] of [0, 0.14, 0.31].entries()) this.gun(at + offset, 0.2 - i * 0.03, 88 - i * 8)
const t = 0.42
for (const [i, note] of ['C4', 'G4'].entries()) {
this.voice(this.sfxBus, 'saw', noteToFreq(note), at + t + i * 0.13, i ? 0.32 : 0.12, 0.1, {
filter: { from: 2400, to: 1200, q: 1 },
})
}
}
/** Armies raised: a drum and a short rising figure, quieter the more often it plays. */
muster() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
this.drum(this.sfxBus, 'K', at, 0.22)
this.seq([['G3', 0.06], ['C4', 0.1]], 'triangle', 0.1)
}
/**
* Cards traded in.
*
* A fanfare, because it is the one moment in the game when armies appear
* out of nowhere and everybody at the table notices. Three notes up and a
* held fourth; the later the set, the more it is worth, and the higher it
* starts.
*/
fanfare(worth: number) {
const lift = Math.min(7, Math.floor(worth / 5))
const root = ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5'][lift]
const f = noteToFreq(root)
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, [ratio, dur]] of [[1, 0.12], [1.25, 0.12], [1.5, 0.12], [2, 0.5]].entries()) {
this.voice(this.sfxBus, 'saw', f * ratio, at + i * 0.14, dur, 0.09, {
filter: { from: 2600, to: 1400, q: 1 },
detune: 5,
})
}
}
/** A player gone from the board, and not you: the drum stopping. */
fallen() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
this.drum(this.sfxBus, 'K', at, 0.3)
this.drum(this.sfxBus, 'K', at + 0.3, 0.2)
this.seq([['.', 0.3], ['E4', 0.18], ['D#4', 0.6]], 'triangle', 0.12)
}
// ---------------------------------------------------------- the endings
private bell(at: number, note: string, partials: readonly (readonly [number, number, number])[]) {
const ctx = this.ensure()
const f = noteToFreq(note)
for (const [mult, gain, dur] of partials) {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = f * mult
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(gain, at + 0.01)
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
osc.connect(env).connect(this.sfxBus)
osc.start(at)
osc.stop(at + dur + 0.05)
}
}
/** The world. Bells, and guns firing at nothing. */
victory() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, note] of ['C5', 'G4', 'E4', 'C4', 'G4', 'C5'].entries()) {
this.bell(at + i * 0.42, note, [[1, 0.13, 2.6], [2.76, 0.05, 1.1], [5.4, 0.025, 0.5]])
}
for (const offset of [0.6, 1.4, 2.1]) this.gun(at + offset, 0.12, 80)
}
/** Somebody else's world: the same bells, in a minor key, for a flag that is not yours. */
defeat() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, note] of ['C4', 'D#4', 'G3', 'C3'].entries()) {
this.bell(at + i * 0.5, note, [[1, 0.13, 3], [2.76, 0.04, 1.2]])
}
}
/** Your last territory. A single low bell and nothing after it. */
eliminated() {
const ctx = this.ensure()
this.bell(ctx.currentTime + 0.01, 'C2', [[1, 0.16, 4], [2.76, 0.05, 1.6]])
}
}
export const synth = new Synth()
+156
View File
@@ -0,0 +1,156 @@
import { memo } from 'react'
import board from '../game/board.json'
import type { Game } from '../game/game'
import { LINK_LIST, TERRITORIES, TERRITORY_IDS, type Continent, type Territory } from '../game/map'
import { PLAYER_NAMES } from '../game/describe'
/**
* The map.
*
* The shapes are real geography, grouped into the forty-two territories by
* `tools/map.py`; the links are the classic board's, from `map.ts`. Where
* the two disagree the rules win and the drawing says so: a dashed lane
* across water that the rules cross, and a closed border -- drawn as a
* fence -- where two territories happen to touch and the rules never let
* anybody across.
*
* Owners are the fill. Continents are the coastline: each one's shore is
* drawn in its own color, under everything, so the six read at a glance
* without taking the fill away from the question that matters, which is
* whose it is.
*/
export const COLORS = ['#d8543f', '#3f7fd8', '#e8b53a', '#3fae7a', '#9a62c9', '#7c8a99'] as const
/** A darker tone of each, for the army badges, so a number is legible on its own color. */
export const DARK = ['#8e2a1b', '#1d4a8e', '#94690f', '#1d6b48', '#5c3182', '#434e5a'] as const
export const SHORE: Record<Continent, string> = {
'north-america': '#e3a33b',
'south-america': '#d4543b',
europe: '#4a79c9',
africa: '#8a5a33',
asia: '#3e9b5a',
australia: '#a052b8',
}
interface Drawn {
d: string
label: [number, number]
}
const DRAWN = board.territories as unknown as Record<Territory, Drawn>
const NEAR = board.near as unknown as Record<string, [number, number, number, number]>
const BORDERS = board.borders as Record<string, string>
const TOUCHING = new Set(board.touching.map(([a, b]) => `${a}|${b}`))
const LINKED = new Set(LINK_LIST.map(([a, b]) => [a, b].sort().join('|')))
const WRAP = board.wrap as unknown as { alaska: [number, number]; kamchatka: [number, number] }
/** Links drawn as lanes: every one the shapes do not already show. */
const LANES = LINK_LIST.map(([a, b]) => [a, b].sort().join('|'))
.filter((k) => !TOUCHING.has(k) && k !== 'alaska|kamchatka')
.map((k) => ({ key: k, xy: NEAR[k] }))
.filter((l) => l.xy)
const edgeKind = (key: string) => {
const [a, b] = key.split('|') as [Territory, Territory]
if (!LINKED.has(key)) return 'closed'
return TERRITORIES[a].continent === TERRITORIES[b].continent ? 'inner' : 'continental'
}
/** Drawn once: nothing about the land or the sea changes during a game. */
const Ground = memo(function Ground() {
return (
<>
<rect className="ocean" x={-40} y={-40} width={board.width + 80} height={board.height + 80} />
<g className="shores">
{TERRITORY_IDS.map((t) => (
<path key={t} d={DRAWN[t].d} stroke={SHORE[TERRITORIES[t].continent]} />
))}
</g>
<g className="lanes">
{LANES.map((l) => (
<line key={l.key} x1={l.xy[0]} y1={l.xy[1]} x2={l.xy[2]} y2={l.xy[3]} />
))}
{/* Across the date line, which is the edge of the board. */}
<line x1={WRAP.alaska[0]} y1={WRAP.alaska[1]} x2={-30} y2={WRAP.alaska[1]} />
<line x1={WRAP.kamchatka[0]} y1={WRAP.kamchatka[1]} x2={board.width + 30} y2={WRAP.kamchatka[1]} />
</g>
</>
)
})
export function Board({
game,
selected,
targets,
target,
flash,
onPick,
}: {
game: Game
selected: Territory | null
targets: readonly Territory[]
target: Territory | null
/** Territories that just changed hands, briefly marked. */
flash: ReadonlySet<Territory>
onPick: (t: Territory) => void
}) {
return (
<svg
className="board"
viewBox={`-30 -10 ${board.width + 60} ${board.height + 20}`}
role="group"
aria-label="The map"
>
<Ground />
<g>
{TERRITORY_IDS.map((t) => {
const owner = game.owner[t]
const cls = [
'land',
t === selected && 'picked',
t === target && 'aimed',
targets.includes(t) && 'open',
flash.has(t) && 'flash',
]
.filter(Boolean)
.join(' ')
return (
<path
key={t}
d={DRAWN[t].d}
className={cls}
fill={COLORS[owner]}
onClick={() => onPick(t)}
>
<title>
{TERRITORIES[t].name}: {PLAYER_NAMES[owner]}, {game.armies[t]}{' '}
{game.armies[t] === 1 ? 'army' : 'armies'}
</title>
</path>
)
})}
</g>
<g className="borders">
{Object.entries(BORDERS).map(([key, d]) => (
<path key={key} d={d} className={edgeKind(key)}>
{edgeKind(key) === 'closed' && <title>A closed border: no link on the classic board</title>}
</path>
))}
</g>
<g className="badges" aria-hidden="true">
{TERRITORY_IDS.map((t) => {
const [x, y] = DRAWN[t].label
const n = game.armies[t]
const r = n >= 100 ? 13 : n >= 10 ? 11 : 9.5
return (
<g key={t} transform={`translate(${x} ${y})`} onClick={() => onPick(t)}>
<circle r={r} fill={DARK[game.owner[t]]} />
<text dy="0.36em">{n}</text>
</g>
)
})}
</g>
</svg>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { PLAYER_NAMES } from '../game/describe'
import { PLAYER_COUNT, STARTING_ARMIES } from '../game/game'
import { CONTINENTS, CONTINENT_IDS } from '../game/map'
import { COLORS, SHORE } from './Board'
/**
* The page before the map.
*
* Everybody thinks they know the rules to this one, and most people learned
* them from somebody who had changed three. So they are stated here in full
* -- they are short -- along with the two or three places where tables
* differ, and which way this one goes.
*
* It is also the honest place for the license and for where the map came
* from, and for saying plainly whose game this is not.
*/
export function Landing({
color,
onPick,
onStart,
}: {
color: number
onPick: (c: number) => void
onStart: () => void
}) {
return (
<div className="landing">
<header>
<h1>World Conquest</h1>
<p className="tagline">
Forty-two territories, six armies, and the dice. You against five, until one of you holds
the world.
</p>
</header>
<div className="cols">
<section>
<h2>The whole game</h2>
<p>
The world is dealt out at random, seven territories each, and everybody puts down{' '}
{STARTING_ARMIES} armies on their own ground, a turn at a time. Then each turn goes the
same way:
</p>
<ol>
<li>
<strong>Reinforce.</strong> A third of your territories, never fewer than three,
plus a bonus for every whole continent you hold.
</li>
<li>
<strong>Attack</strong> as often as you like, from any territory with an army to
spare into any enemy territory next to it. Up to three dice attack and up to two
defend; highest against highest, and a tie goes to the defender. Empty a territory
and you march in.
</li>
<li>
<strong>Fortify</strong> once: move armies between two of your territories joined
by your own ground. Then the turn passes.
</li>
</ol>
<p>
Take at least one territory in a turn and you draw a card. Three alike, or one of
each, trade in for armies: 4, then 6, 8, 10, 12, 15, and five more every time after.
The count is shared by the whole table, so every set traded makes the next one
dearer for everybody. Hold five cards and you must trade. Finish a player off and
their cards are yours.
</p>
</section>
<section>
<h2>The continents</h2>
<ul className="continents">
{CONTINENT_IDS.map((c) => (
<li key={c}>
<span className="shore" style={{ borderColor: SHORE[c] }} />
{CONTINENTS[c].name} <span className="dim">+{CONTINENTS[c].bonus} a turn</span>
</li>
))}
</ul>
<p>
The shapes on the map are the real ones; the links are the classic board's. Dashed
lines cross the water where the rules do, and Alaska meets Kamchatka off the edge of
the world. The one border drawn as a fence, between Siberia and the steppe, is a place
the real map touches and the rules never let anybody across.
</p>
<h2>Where tables differ</h2>
<p>
Fortifying goes anywhere along a chain of your own territories, not only next door.
The defender always rolls every die they can. Sets escalate for the whole table rather
than staying fixed. A card showing a territory you hold puts two more armies there
when you trade it.
</p>
</section>
</div>
<div className="choose">
<h2>Take a color</h2>
<ul>
{Array.from({ length: PLAYER_COUNT }, (_, c) => (
<li key={c}>
<button type="button" className={c === color ? 'on' : ''} aria-pressed={c === color} onClick={() => onPick(c)}>
<span className="swatch" style={{ background: COLORS[c] }} />
{PLAYER_NAMES[c]}
</button>
</li>
))}
</ul>
<button type="button" className="start" onClick={onStart}>
Deal
</button>
</div>
<footer>
<p className="ways">
{/*
Absolute rather than "/", because this game is served from a
subdirectory in production and from the root in development.
*/}
<a href="https://games.jcoffey.dev/">The rest of the games</a>
{' · '}
<a href="https://github.com/jcoffey-dev/world-conquest">Source</a>
</p>
<p>
World Conquest 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 coastlines and borders are from <a href="https://www.naturalearthdata.com/">Natural Earth</a>,
which is in the public domain. Which country belongs to which territory is this
project's own choice.
</p>
<p>
This is not the board game it is modeled on, 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>
)
}
+473
View File
@@ -0,0 +1,473 @@
import { useState } from 'react'
import { isSet, setsIn, type Card } from '../game/cards'
import { winChance } from '../game/dice'
import { PLAYER_NAMES, describe } from '../game/describe'
import type { Game } from '../game/game'
import { CONTINENTS, CONTINENT_IDS, TERRITORIES, inContinent, type Territory } from '../game/map'
import { COLORS, SHORE } from './Board'
/**
* Everything beside the map: whose turn it is and what they can do, the
* dice, your cards, the table and the log.
*
* The map is where you point; this is where you are told what pointing
* will do. Every control here is only ever offered when the rules would
* take it, which is why so much of it is conditional.
*/
interface Acts {
place: (t: Territory, n: number) => void
trade: (cards: [number, number, number]) => void
beginAttack: () => void
roll: (dice: number) => void
blitz: () => void
occupy: (n: number) => void
endAttack: () => void
fortify: (n: number) => void
endTurn: () => void
autoSetup: () => void
}
const Chip = ({ p }: { p: number }) => <span className="chip" style={{ background: COLORS[p] }} />
const name = (t: Territory) => TERRITORIES[t].name
/** A chance as a player would say it: never a flat 0% or 100% while the dice can still disagree. */
function percent(p: number) {
if (p >= 0.995) return 'better than 99%'
if (p > 0 && p < 0.005) return 'less than 1%'
return `${Math.round(p * 100)}%`
}
export function Side({
game: g,
human,
sel,
target,
autoSetup,
step,
onStep,
act,
}: {
game: Game
human: number
sel: Territory | null
target: Territory | null
autoSetup: boolean
step: 1 | 5 | 'all'
onStep: (s: 1 | 5 | 'all') => void
act: Acts
}) {
const mine = g.current === human && g.phase !== 'over'
const who = (p: number) => (p === human ? 'You' : PLAYER_NAMES[p])
return (
<aside className="side">
<section className="now">
<p className="turn">
<Chip p={g.current} />
{g.phase === 'over'
? `${who(g.winner!)} ${g.winner === human ? 'hold' : 'holds'} the world`
: g.phase === 'setup'
? 'Placing armies'
: `Round ${g.round} · ${mine ? 'your turn' : `${PLAYER_NAMES[g.current]}'s turn`}`}
</p>
{mine ? (
<Controls g={g} sel={sel} target={target} autoSetup={autoSetup} step={step} onStep={onStep} act={act} />
) : g.phase !== 'over' ? (
<p className="dim">{g.phase === 'setup' ? 'Everybody puts down their armies in turn.' : 'Watching.'}</p>
) : null}
</section>
<LastThrow g={g} human={human} />
<Hand g={g} human={human} act={act} />
<section>
<h2>The table</h2>
<table className="table">
<thead>
<tr>
<th />
<th>Lands</th>
<th>Armies</th>
<th>Cards</th>
<th>Pays</th>
</tr>
</thead>
<tbody>
{g.players.map((p) => (
<tr key={p.id} className={[p.alive ? '' : 'gone', p.id === g.current ? 'now' : ''].join(' ')}>
<th>
<Chip p={p.id} />
{p.id === human ? 'You' : PLAYER_NAMES[p.id]}
</th>
<td>{g.territoriesOf(p.id).length}</td>
<td>{g.armiesOf(p.id)}</td>
<td>{p.cards.length}</td>
<td>{p.alive ? g.income(p.id) : '-'}</td>
</tr>
))}
</tbody>
</table>
</section>
<section>
<h2>Continents</h2>
<ul className="continents">
{CONTINENT_IDS.map((c) => {
const land = inContinent(c)
const holder = g.owner[land[0]]
const whole = land.every((t) => g.owner[t] === holder)
return (
<li key={c}>
<span className="shore" style={{ borderColor: SHORE[c] }} />
{CONTINENTS[c].name} <span className="dim">+{CONTINENTS[c].bonus}</span>
{whole && (
<span className="held">
<Chip p={holder} />
{who(holder)}
</span>
)}
</li>
)
})}
</ul>
</section>
<Log g={g} human={human} />
</aside>
)
}
// ------------------------------------------------------------ controls
function Controls({
g,
sel,
target,
autoSetup,
step,
onStep,
act,
}: {
g: Game
sel: Territory | null
target: Territory | null
autoSetup: boolean
step: 1 | 5 | 'all'
onStep: (s: 1 | 5 | 'all') => void
act: Acts
}) {
switch (g.phase) {
case 'setup':
return (
<>
<p>
<strong>{g.toPlace[g.current]}</strong> armies to put down, one at a time, taking turns.
Click one of your territories.
</p>
{!autoSetup && (
<button type="button" onClick={act.autoSetup}>
Place the rest for me
</button>
)}
</>
)
case 'reinforce':
if (g.mustTrade)
return (
<p>
<strong>{g.hand.length} cards</strong> in hand: trade a set before anything else. Your cards
are below.
</p>
)
if (g.reinforcements > 0)
return (
<>
<p>
<strong>{g.reinforcements}</strong> to place. Click your territories.
{g.resume && ' Then back to the fighting.'}
</p>
<div className="row" role="group" aria-label="Armies per click">
{([1, 5, 'all'] as const).map((s) => (
<button key={s} type="button" aria-pressed={step === s} className="toggle" onClick={() => onStep(s)}>
{s === 'all' ? 'All' : `+${s}`}
</button>
))}
</div>
</>
)
return (
<>
<p>All placed. Trade another set now, or go to war.</p>
<button type="button" className="go" onClick={act.beginAttack}>
Attack
</button>
</>
)
case 'attack':
return <Attack g={g} sel={sel} target={target} act={act} />
case 'occupy':
return <Occupy key={g.events.length} g={g} act={act} />
case 'fortify':
return <Fortify key={`${sel}-${target}`} g={g} sel={sel} target={target} act={act} />
default:
return null
}
}
function Attack({ g, sel, target, act }: { g: Game; sel: Territory | null; target: Territory | null; act: Acts }) {
const most = sel ? Math.min(3, g.armies[sel] - 1) : 0
return (
<>
{!sel && <p>Pick one of your territories with an army to spare and an enemy next door.</p>}
{sel && !target && (
<p>
From <strong>{name(sel)}</strong> ({g.armies[sel]}). Now pick who to attack.
</p>
)}
{sel && target && (
<>
<p>
<strong>{name(sel)}</strong> ({g.armies[sel]}) against <strong>{name(target)}</strong> (
{g.armies[target]}, {PLAYER_NAMES[g.owner[target]]})
</p>
<p className="odds">Pressed to the end, {percent(winChance(g.armies[sel] - 1, g.armies[target]))} to take it.</p>
<div className="row">
{[3, 2, 1]
.filter((d) => d <= most)
.map((d) => (
<button key={d} type="button" onClick={() => act.roll(d)}>
Roll {d}
</button>
))}
<button type="button" className="go" onClick={act.blitz}>
Blitz
</button>
</div>
</>
)}
<button type="button" className="quiet" onClick={act.endAttack}>
Stop attacking
</button>
</>
)
}
function Occupy({ g, act }: { g: Game; act: Acts }) {
const o = g.occupation!
const [n, setN] = useState(o.max)
return (
<>
<p>
<strong>{name(o.to)}</strong> is yours. How many march in from {name(o.from)}?
</p>
{o.max > o.min && (
<input
type="range"
min={o.min}
max={o.max}
value={n}
onChange={(e) => setN(Number(e.target.value))}
aria-label="Armies to move in"
/>
)}
<div className="row">
<button type="button" className="go" onClick={() => act.occupy(n)}>
Move {n} in
</button>
{o.max > o.min && (
<span className="dim">
leaving {g.armies[o.from] - n} behind
</span>
)}
</div>
</>
)
}
function Fortify({ g, sel, target, act }: { g: Game; sel: Territory | null; target: Territory | null; act: Acts }) {
const max = sel ? g.armies[sel] - 1 : 0
const [n, setN] = useState(max)
return (
<>
<p>
{!sel
? 'One move to finish: pick a territory to move armies from, or end the turn.'
: !target
? `From ${name(sel)}. Pick where to, anywhere joined to it by your own territories.`
: `${name(sel)} to ${name(target)}.`}
</p>
{sel && target && (
<>
{max > 1 && (
<input
type="range"
min={1}
max={max}
value={n}
onChange={(e) => setN(Number(e.target.value))}
aria-label="Armies to move"
/>
)}
<button type="button" className="go" onClick={() => act.fortify(n)}>
Move {n} and end turn
</button>
</>
)}
<button type="button" className="quiet" onClick={act.endTurn}>
End turn
</button>
</>
)
}
// ------------------------------------------------------------ the dice
function Die({ face, side }: { face: number; side: 'attack' | 'defend' }) {
const pips: Record<number, [number, number][]> = {
1: [[2, 2]],
2: [[1, 1], [3, 3]],
3: [[1, 1], [2, 2], [3, 3]],
4: [[1, 1], [3, 1], [1, 3], [3, 3]],
5: [[1, 1], [3, 1], [2, 2], [1, 3], [3, 3]],
6: [[1, 1], [3, 1], [1, 2], [3, 2], [1, 3], [3, 3]],
}
return (
<svg className={`die ${side}`} viewBox="0 0 4 4" role="img" aria-label={`${face}`}>
<rect x={0.2} y={0.2} width={3.6} height={3.6} rx={0.7} />
{pips[face].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={0.36} />
))}
</svg>
)
}
function LastThrow({ g, human }: { g: Game; human: number }) {
const last = g.events.findLast((e) => e.kind === 'battle')
if (!last || last.kind !== 'battle') return null
const { roll } = last
const who = (p: number) => (p === human ? 'You' : PLAYER_NAMES[p])
return (
<section className="throw" aria-live="polite">
<p className="dim">
{who(last.player)}, {name(last.from)} {name(last.to)}
</p>
<div className="dice">
<div>
{roll.attack.map((f, i) => (
<Die key={i} face={f} side="attack" />
))}
</div>
<span className="vs">vs</span>
<div>
{roll.defend.map((f, i) => (
<Die key={i} face={f} side="defend" />
))}
</div>
</div>
<p className="dim">
Attacker lost {roll.attackerLoss}, defender lost {roll.defenderLoss}.
</p>
</section>
)
}
// ------------------------------------------------------------ the cards
const MARK_GLYPH: Record<Card['mark'], string> = {
infantry: '▲',
cavalry: '●',
artillery: '■',
wild: '★',
}
function Hand({ g, human, act }: { g: Game; human: number; act: Acts }) {
const hand = g.players[human].cards
const [chosen, setChosen] = useState<number[]>([])
const canTrade = g.current === human && g.phase === 'reinforce'
const picked = chosen.filter((i) => i < hand.length)
const cards = picked.map((i) => hand[i])
const ready = picked.length === 3 && isSet(cards)
const sets = setsIn(hand)
const toggle = (i: number) =>
setChosen((c) => (c.includes(i) ? c.filter((x) => x !== i) : c.length < 3 ? [...c, i] : c))
return (
<section>
<h2>
Your cards <span className="dim">· next set pays {g.nextSetValue}</span>
</h2>
{hand.length === 0 ? (
<p className="dim">None yet. Take a territory in a turn and you draw one at the end of it.</p>
) : (
<ul className="cards">
{hand.map((c, i) => (
<li key={i}>
<button
type="button"
className={`card ${c.mark}`}
aria-pressed={picked.includes(i)}
disabled={!canTrade}
onClick={() => toggle(i)}
>
<span className="glyph">{MARK_GLYPH[c.mark]}</span>
<span>{c.territory ? name(c.territory) : 'Wild'}</span>
{c.territory && g.owner[c.territory] === human && <span className="own">yours</span>}
</button>
</li>
))}
</ul>
)}
{canTrade && sets.length > 0 && (
<div className="row">
<button
type="button"
className={ready ? 'go' : ''}
disabled={!ready}
onClick={() => {
act.trade(picked as [number, number, number])
setChosen([])
}}
>
Trade for {g.nextSetValue}
</button>
{!ready && (
<button type="button" onClick={() => setChosen(sets[0])}>
Pick a set
</button>
)}
</div>
)}
{hand.length > 0 && (
<p className="dim small">Three alike, one of each, or any two with a wild.</p>
)}
</section>
)
}
// -------------------------------------------------------------- the log
function Log({ g, human }: { g: Game; human: number }) {
const lines = describe(g.events, human).slice(-60).reverse()
return (
<section className="log">
<h2>What happened</h2>
<ol>
{lines.map((l, i) => (
<li key={lines.length - i} className={l.heading ? 'heading' : ''}>
<span className="chip" style={{ background: COLORS[l.player] }} />
{l.text}
</li>
))}
</ol>
</section>
)
}
File diff suppressed because one or more lines are too long
+268
View File
@@ -0,0 +1,268 @@
import { setsIn, type Card } from './cards'
import { winChance } from './dice'
import type { Game } from './game'
import {
ADJACENT,
BORDERS,
CONTINENTS,
CONTINENT_IDS,
TERRITORIES,
inContinent,
type Continent,
type Territory,
} from './map'
import { makeRng } from './rng'
/**
* The five opponents.
*
* A bot keeps no plan between moves. Every time it is asked, it looks at the
* board and answers with the one thing it would do next -- trade, place,
* throw the dice, march in, move up, or stop -- so it can never be caught
* holding a plan the dice have already made nonsense of, and a game can be
* stopped and resumed anywhere without anybody's intentions going stale.
*
* What it wants comes down to three things, in this order: a continent it
* can hold, a card for the turn, and nobody else holding a continent in
* peace. Every attack is priced as what winning is worth times the exact
* chance of winning it, from `dice.ts` -- not a rule of thumb about armies.
* A player watching one of these turn on them is owed a reason, and "it had
* a 71% chance at Brazil and Brazil finished South America" is one.
*/
export type Action =
| { type: 'place'; territory: Territory; count: number }
| { type: 'trade'; cards: [number, number, number] }
| { type: 'beginAttack' }
| { type: 'attack'; from: Territory; to: Territory }
| { type: 'occupy'; count: number }
| { type: 'endAttack' }
| { type: 'fortify'; from: Territory; to: Territory; count: number }
| { type: 'endTurn' }
export interface Leaning {
/** 0 is careful, 1 is reckless. Moves the odds a bot will fight at. */
nerve: number
/** A small private taste for some continents over others. */
taste: Record<Continent, number>
}
/**
* Six minds reasoning identically play the same game every time. Each one
* leans a little -- braver or more careful, fonder of one continent -- and
* the lean comes from the seed, so a seed still replays a game exactly.
*/
export function leanings(seed: number, players: number): Leaning[] {
return Array.from({ length: players }, (_, id) => {
const rng = makeRng(seed * 31 + id * 7919 + 1)
return {
nerve: rng(),
taste: Object.fromEntries(CONTINENT_IDS.map((c) => [c, 0.85 + rng() * 0.3])) as Record<
Continent,
number
>,
}
})
}
// ------------------------------------------------------------ appraisal
/** How hard each continent would be to take from here, and what it pays. */
export function goal(g: Game, p: number, lean: Leaning): Continent | null {
let best: Continent | null = null
let bestScore = -Infinity
for (const c of CONTINENT_IDS) {
const land = inContinent(c)
const mine = land.filter((t) => g.owner[t] === p)
if (mine.length === land.length) continue
if (mine.length === 0 && !land.some((t) => ADJACENT[t].some((n) => g.owner[n] === p))) continue
const theirs = land.filter((t) => g.owner[t] !== p).reduce((n, t) => n + g.armies[t], 0)
const ours = mine.reduce((n, t) => n + g.armies[t], 0)
const held = mine.length / land.length
// What it pays, made cheaper by what is already there and dearer by the
// opposition and by every door that will need watching afterwards.
const score =
(CONTINENTS[c].bonus * (1 + held) ** 2 * lean.taste[c] * (1 + ours / 10)) /
((theirs + 1) * Math.sqrt(BORDERS[c].length))
if (score > bestScore) {
bestScore = score
best = c
}
}
return best
}
const enemies = (g: Game, t: Territory) => ADJACENT[t].filter((n) => g.owner[n] !== g.owner[t])
const threat = (g: Game, t: Territory) => enemies(g, t).reduce((n, e) => n + g.armies[e], 0)
/** What taking `t` would be worth to `p`, before the odds. */
export function worth(g: Game, p: number, t: Territory, target: Continent | null): number {
const c = TERRITORIES[t].continent
const holder = g.owner[t]
const land = inContinent(c)
let v = 1
if (c === target) v += 3
// The last one in a continent pays out every turn.
if (land.every((x) => x === t || g.owner[x] === p)) v += CONTINENTS[c].bonus * 2
// Somebody else's continent stops paying the moment one territory goes.
if (land.every((x) => g.owner[x] === holder)) v += CONTINENTS[c].bonus * 1.5
// Finishing a player off hands over their cards.
const left = g.territoriesOf(holder).length
if (left <= 2) v += 2 + g.players[holder].cards.length * 2
// A territory taken this turn is a card at the end of it; the first one
// is worth more than any after.
if (!g.conquered) v += 2
return v
}
interface Plan {
from: Territory
to: Territory
odds: number
value: number
}
function bestAttack(g: Game, p: number, lean: Leaning): Plan | null {
const target = goal(g, p, lean)
// The odds a bot will fight at: between about 55% and 75%, by nerve. For
// the first territory of the turn it will go lower, because the card is
// on the line and a card is armies later.
const floor = 0.75 - lean.nerve * 0.2
let best: Plan | null = null
for (const from of g.territoriesOf(p)) {
if (g.armies[from] < 2) continue
for (const to of enemies(g, from)) {
const odds = winChance(g.armies[from] - 1, g.armies[to])
const needed = g.conquered ? floor : floor - 0.1
if (odds < needed) continue
const value = worth(g, p, to, target) * odds
if (!best || value > best.value) best = { from, to, odds, value }
}
}
return best
}
/** Where the next army does most good: somewhere to attack from, or a door to hold. */
function stagingPoint(g: Game, p: number, lean: Leaning): Territory {
const target = goal(g, p, lean)
const held = g.continentsOf(p)
let best: Territory | null = null
let bestScore = -Infinity
for (const t of g.territoriesOf(p)) {
const foes = enemies(g, t)
if (foes.length === 0) continue
let score = 0
// Attack: the best thing next door, cheapest to take.
for (const n of foes) {
const s = worth(g, p, n, target) / (1 + g.armies[n])
if (s > score) score = s
}
// Defence: a door of a continent we hold, outnumbered.
const door = held.some((c) => BORDERS[c].includes(t))
if (door) score += Math.max(0, threat(g, t) - g.armies[t]) * 0.4 + 1
// Pile up rather than spread thin: a stack attacks, a scatter does not.
score += Math.min(g.armies[t], 12) * 0.05
if (score > bestScore) {
bestScore = score
best = t
}
}
return best ?? g.territoriesOf(p)[0]
}
/** The set that pays most: one showing a territory we hold, and wild cards kept if possible. */
function bestSet(g: Game, p: number, hand: readonly Card[]): [number, number, number] | null {
const sets = setsIn(hand)
if (sets.length === 0) return null
const score = (s: [number, number, number]) =>
s.reduce((n, i) => {
const c = hand[i]
if (c.mark === 'wild') return n - 1
return n + (c.territory && g.owner[c.territory] === p ? 2 : 0)
}, 0)
return sets.reduce((a, b) => (score(b) > score(a) ? b : a))
}
// ------------------------------------------------------------- deciding
export function decide(g: Game, lean: Leaning): Action {
const p = g.current
switch (g.phase) {
case 'setup':
return { type: 'place', territory: stagingPoint(g, p, lean), count: 1 }
case 'reinforce': {
// Trading now rather than saving up: the value rises for everybody
// whether or not we wait, and armies now are armies on the board.
const set = bestSet(g, p, g.hand)
if (set && (g.mustTrade || g.reinforcements > 0 || g.resume)) return { type: 'trade', cards: set }
if (g.reinforcements > 0) {
// Most on the best point, and the rest reconsidered, so a big
// income can cover a threatened door as well as a push.
const count = g.reinforcements <= 3 ? g.reinforcements : Math.ceil(g.reinforcements * 0.7)
return { type: 'place', territory: stagingPoint(g, p, lean), count }
}
return { type: 'beginAttack' }
}
case 'attack': {
const plan = bestAttack(g, p, lean)
return plan ? { type: 'attack', from: plan.from, to: plan.to } : { type: 'endAttack' }
}
case 'occupy': {
const o = g.occupation!
const behind = threat(g, o.from)
const ahead = threat(g, o.to)
let count: number
if (behind === 0) count = o.max
else if (ahead === 0) count = o.min
else count = Math.round(o.max * (ahead / (ahead + behind * 0.6)))
return { type: 'occupy', count: Math.max(o.min, Math.min(o.max, count)) }
}
case 'fortify': {
// The biggest idle stack -- one with no enemy beside it -- walks to the
// front that needs it most.
const idle = g
.territoriesOf(p)
.filter((t) => g.armies[t] > 1 && enemies(g, t).length === 0)
.sort((a, b) => g.armies[b] - g.armies[a])
for (const from of idle) {
const fronts = g.connected(from).filter((t) => enemies(g, t).length > 0)
if (fronts.length === 0) continue
const to = fronts.reduce((a, b) =>
threat(g, b) - g.armies[b] > threat(g, a) - g.armies[a] ? b : a,
)
return { type: 'fortify', from, to, count: g.armies[from] - 1 }
}
return { type: 'endTurn' }
}
case 'over':
throw new Error('the game is over')
}
}
export function apply(g: Game, a: Action) {
switch (a.type) {
case 'place':
return g.place(a.territory, a.count)
case 'trade':
return g.trade(a.cards)
case 'beginAttack':
return g.beginAttack()
case 'attack':
return g.attack(a.from, a.to)
case 'occupy':
return g.occupy(a.count)
case 'endAttack':
return g.endAttack()
case 'fortify':
return g.fortify(a.from, a.to, a.count)
case 'endTurn':
return g.endTurn()
}
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { fullDeck, isSet, setValue, setsIn, type Card } from './cards'
const c = (mark: Card['mark']): Card => ({ territory: null, mark })
describe('the deck', () => {
it('is forty-two territories, fourteen of each mark, and two wild', () => {
const deck = fullDeck()
expect(deck).toHaveLength(44)
for (const m of ['infantry', 'cavalry', 'artillery'] as const)
expect(deck.filter((x) => x.mark === m)).toHaveLength(14)
expect(deck.filter((x) => x.mark === 'wild')).toHaveLength(2)
})
})
describe('sets', () => {
it('are three alike, one of each, or anything with a wild', () => {
expect(isSet([c('infantry'), c('infantry'), c('infantry')])).toBe(true)
expect(isSet([c('infantry'), c('cavalry'), c('artillery')])).toBe(true)
expect(isSet([c('infantry'), c('infantry'), c('wild')])).toBe(true)
expect(isSet([c('infantry'), c('infantry'), c('cavalry')])).toBe(false)
})
it('five cards always hold one', () => {
const marks = ['infantry', 'cavalry', 'artillery'] as const
for (let a = 0; a < 3; a++)
for (let b = 0; b < 3; b++)
for (let d = 0; d < 3; d++)
for (let e = 0; e < 3; e++)
for (let f = 0; f < 3; f++)
expect(setsIn([a, b, d, e, f].map((i) => c(marks[i]))).length).toBeGreaterThan(0)
})
it('are worth more each time anybody trades one', () => {
expect([0, 1, 2, 3, 4, 5, 6, 7, 8].map(setValue)).toEqual([4, 6, 8, 10, 12, 15, 20, 25, 30])
})
})
+56
View File
@@ -0,0 +1,56 @@
import { TERRITORY_IDS, type Territory } from './map'
/**
* The deck: one card per territory, each carrying one of three marks, and two
* wild cards that stand for any of them.
*
* The marks are ours. The published game draws a soldier, a horseman and a
* cannon; what matters to the rules is only that there are three kinds,
* fourteen of each, so they are named for what they are and drawn as plain
* shapes.
*/
export type Mark = 'infantry' | 'cavalry' | 'artillery'
export interface Card {
/** Null for a wild card. */
territory: Territory | null
mark: Mark | 'wild'
}
const MARKS: Mark[] = ['infantry', 'cavalry', 'artillery']
export function fullDeck(): Card[] {
const cards: Card[] = TERRITORY_IDS.map((territory, i) => ({ territory, mark: MARKS[i % 3] }))
cards.push({ territory: null, mark: 'wild' }, { territory: null, mark: 'wild' })
return cards
}
/** Three of a kind, one of each, or anything with a wild card in it. */
export function isSet(cards: readonly Card[]): boolean {
if (cards.length !== 3) return false
if (cards.some((c) => c.mark === 'wild')) return true
const marks = new Set(cards.map((c) => c.mark))
return marks.size === 1 || marks.size === 3
}
/**
* What the nth set traded in by anybody is worth: 4, 6, 8, 10, 12, 15, and
* five more each time after that. The count is shared by the whole table,
* which is why the value of holding cards rises while nobody else trades.
*/
export function setValue(tradesSoFar: number): number {
const early = [4, 6, 8, 10, 12, 15]
if (tradesSoFar < early.length) return early[tradesSoFar]
return 15 + 5 * (tradesSoFar - early.length + 1)
}
/** Every set that can be made from a hand, as index triples. */
export function setsIn(hand: readonly Card[]): [number, number, number][] {
const out: [number, number, number][] = []
for (let i = 0; i < hand.length; i++)
for (let j = i + 1; j < hand.length; j++)
for (let k = j + 1; k < hand.length; k++)
if (isSet([hand[i], hand[j], hand[k]])) out.push([i, j, k])
return out
}
+32
View File
@@ -0,0 +1,32 @@
import { describe as suite, expect, it } from 'vitest'
import { describe } from './describe'
import type { GameEvent } from './game'
const throwOf = (attackerLoss: number, defenderLoss: number): GameEvent => ({
kind: 'battle',
player: 1,
from: 'peru',
to: 'brazil',
defender: 0,
roll: { attack: [], defend: [], attackerLoss, defenderLoss },
})
suite('the log', () => {
it('folds a run of throws into one line', () => {
const lines = describe([throwOf(1, 1), throwOf(2, 0), { kind: 'card', player: 1 }], 0)
expect(lines.map((l) => l.text)).toEqual(['Cobalt attacked Brazil from Peru: lost 3, killed 1.'])
})
it('lets a conquest speak for the fight that won it', () => {
const lines = describe(
[throwOf(0, 2), throwOf(1, 1), { kind: 'conquer', player: 1, from: 'peru', to: 'brazil', loser: 0 }],
0,
)
expect(lines.map((l) => l.text)).toEqual(['Cobalt took Brazil from you (lost 1, killed 3).'])
})
it('speaks to the player in the second person', () => {
const lines = describe([{ kind: 'eliminate', player: 3, loser: 0, cards: 2 }], 0)
expect(lines[0].text).toBe('You are out, finished by Jade, who takes 2 cards.')
})
})
+106
View File
@@ -0,0 +1,106 @@
import type { GameEvent } from './game'
import { TERRITORIES, type Territory } from './map'
/**
* The log, in words.
*
* A game of this throws the dice thousands of times and nobody wants to read
* a line for each throw. So consecutive throws between the same two
* territories fold into one line saying how the fight went, and the log
* reads like an account of the war rather than a transcript of the dice.
*/
export const PLAYER_NAMES = ['Crimson', 'Cobalt', 'Saffron', 'Jade', 'Violet', 'Ash'] as const
export interface Line {
/** Whose line it is, for the color chip. */
player: number
text: string
/** A new turn starts a new paragraph. */
heading?: boolean
}
const name = (t: Territory) => TERRITORIES[t].name
export function describe(events: readonly GameEvent[], human: number | null): Line[] {
const who = (p: number) => (p === human ? 'You' : PLAYER_NAMES[p])
const whom = (p: number) => (p === human ? 'you' : PLAYER_NAMES[p])
const lines: Line[] = []
let fight: { player: number; from: Territory; to: Territory; lost: number; killed: number; defender: number } | null =
null
const closeFight = (taken: boolean) => {
if (!fight) return
const f = fight
fight = null
if (taken) return // the conquest line says it better
lines.push({
player: f.player,
text: `${who(f.player)} attacked ${name(f.to)} from ${name(f.from)}: lost ${f.lost}, killed ${f.killed}.`,
})
}
for (const e of events) {
if (e.kind !== 'battle' && e.kind !== 'conquer') closeFight(false)
switch (e.kind) {
case 'turn':
lines.push({
player: e.player,
heading: true,
text: `Round ${e.round}: ${who(e.player)} ${e.player === human ? 'have' : 'has'} ${
e.reinforcements
} to place.`,
})
break
case 'battle':
if (fight && (fight.from !== e.from || fight.to !== e.to)) closeFight(false)
fight ??= { player: e.player, from: e.from, to: e.to, lost: 0, killed: 0, defender: e.defender }
fight.lost += e.roll.attackerLoss
fight.killed += e.roll.defenderLoss
break
case 'conquer': {
const f = fight
closeFight(true)
const cost = f ? ` (lost ${f.lost}, killed ${f.killed})` : ''
lines.push({
player: e.player,
text: `${who(e.player)} took ${name(e.to)} from ${whom(e.loser)}${cost}.`,
})
break
}
case 'trade':
lines.push({
player: e.player,
text: `${who(e.player)} traded a set for ${e.armies}${
e.bonus.length ? `, and 2 more on ${name(e.bonus[0])}` : ''
}.`,
})
break
case 'eliminate':
lines.push({
player: e.player,
text: `${who(e.loser)} ${e.loser === human ? 'are' : 'is'} out, finished by ${whom(e.player)}${
e.cards ? `, who takes ${e.cards} card${e.cards === 1 ? '' : 's'}` : ''
}.`,
})
break
case 'fortify':
lines.push({
player: e.player,
text: `${who(e.player)} moved ${e.count} from ${name(e.from)} to ${name(e.to)}.`,
})
break
case 'win':
lines.push({
player: e.player,
heading: true,
text: `${who(e.player)} ${e.player === human ? 'hold' : 'holds'} the world.`,
})
break
default:
break
}
}
closeFight(false)
return lines
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { OUTCOMES, compare, roll, winChance } from './dice'
import { makeRng } from './rng'
describe('one throw', () => {
it('matches highest against highest, and a tie goes to the defender', () => {
expect(compare([6, 1, 3], [6, 2])).toMatchObject({ attackerLoss: 1, defenderLoss: 1 })
expect(compare([5, 5, 5], [4, 4])).toMatchObject({ attackerLoss: 0, defenderLoss: 2 })
expect(compare([2], [2, 1])).toMatchObject({ attackerLoss: 1, defenderLoss: 0 })
})
it('rolls the dice it was asked for', () => {
const r = roll(makeRng(1), 3, 2)
expect(r.attack).toHaveLength(3)
expect(r.defend).toHaveLength(2)
expect(r.attackerLoss + r.defenderLoss).toBe(2)
})
})
describe('the odds', () => {
// The figures everybody quotes, to four places.
it('agrees with the published exchange tables', () => {
const three = OUTCOMES['3v2']
expect(three.get(0)).toBeCloseTo(2890 / 7776, 10)
expect(three.get(1)).toBeCloseTo(2611 / 7776, 10)
expect(three.get(2)).toBeCloseTo(2275 / 7776, 10)
expect(OUTCOMES['1v1'].get(0)).toBeCloseTo(15 / 36, 10)
expect(OUTCOMES['3v1'].get(0)).toBeCloseTo(855 / 1296, 10)
expect(OUTCOMES['2v2'].get(0)).toBeCloseTo(295 / 1296, 10)
})
it('knows what a whole battle is worth', () => {
expect(winChance(1, 1)).toBeCloseTo(0.4167, 3)
expect(winChance(3, 2)).toBeCloseTo(0.6560, 3)
// Two against one: win the first exchange, or lose it and win one-on-one.
expect(winChance(2, 1)).toBeCloseTo(125 / 216 + (91 / 216) * (15 / 36), 12)
expect(winChance(10, 10)).toBeGreaterThan(0.5)
expect(winChance(5, 0)).toBe(1)
expect(winChance(0, 3)).toBe(0)
})
it('agrees with the dice when they are actually thrown', () => {
const rng = makeRng(99)
let wins = 0
const games = 20000
for (let i = 0; i < games; i++) {
let a = 5
let d = 4
while (a > 0 && d > 0) {
const r = roll(rng, Math.min(3, a), Math.min(2, d))
a -= r.attackerLoss
d -= r.defenderLoss
}
if (d === 0) wins++
}
expect(wins / games).toBeCloseTo(winChance(5, 4), 1)
})
})
+99
View File
@@ -0,0 +1,99 @@
import type { Rng } from './rng'
/**
* The dice, and the arithmetic of them.
*
* An attacker rolls up to three, never more than the armies it has beyond
* the one that must stay home. A defender rolls up to two, never more than
* it has. Highest against highest, second against second; a tie goes to the
* defender, and each comparison costs the loser one army.
*
* The odds are worked out here rather than looked up. A table copied from
* somewhere is a table nobody can check; one computed from thirty-six and
* two hundred and sixteen and seven thousand seven hundred and seventy-six
* equally likely throws is its own proof, and the tests hold it to the
* figures every Risk player's cousin has quoted at them.
*/
export interface Roll {
attack: number[]
defend: number[]
attackerLoss: number
defenderLoss: number
}
const die = (rng: Rng) => 1 + Math.floor(rng() * 6)
export function compare(attack: number[], defend: number[]) {
const a = [...attack].sort((x, y) => y - x)
const d = [...defend].sort((x, y) => y - x)
let attackerLoss = 0
let defenderLoss = 0
for (let i = 0; i < Math.min(a.length, d.length); i++) {
if (a[i] > d[i]) defenderLoss++
else attackerLoss++
}
return { attack: a, defend: d, attackerLoss, defenderLoss }
}
export function roll(rng: Rng, attackDice: number, defendDice: number): Roll {
const attack = Array.from({ length: attackDice }, () => die(rng))
const defend = Array.from({ length: defendDice }, () => die(rng))
return compare(attack, defend)
}
/** Every throw of `n` dice, each equally likely. */
function throws(n: number): number[][] {
if (n === 0) return [[]]
const out: number[][] = []
for (const rest of throws(n - 1)) for (let f = 1; f <= 6; f++) out.push([f, ...rest])
return out
}
/**
* For one exchange of `a` attacking and `d` defending dice: the chance of
* each outcome, keyed by how many armies the attacker loses.
*/
export const OUTCOMES: Record<string, Map<number, number>> = (() => {
const table: Record<string, Map<number, number>> = {}
for (let a = 1; a <= 3; a++) {
for (let d = 1; d <= 2; d++) {
const counts = new Map<number, number>()
let total = 0
for (const at of throws(a)) {
for (const de of throws(d)) {
const { attackerLoss } = compare(at, de)
counts.set(attackerLoss, (counts.get(attackerLoss) ?? 0) + 1)
total++
}
}
for (const [k, v] of counts) counts.set(k, v / total)
table[`${a}v${d}`] = counts
}
}
return table
})()
const memo = new Map<string, number>()
/**
* The chance that `attackers` armies -- the ones free to attack, not counting
* the one left behind -- take a territory held by `defenders`, if the attack
* is pressed with every die available until one side is gone.
*/
export function winChance(attackers: number, defenders: number): number {
if (defenders <= 0) return 1
if (attackers <= 0) return 0
const key = `${attackers},${defenders}`
const hit = memo.get(key)
if (hit !== undefined) return hit
const a = Math.min(3, attackers)
const d = Math.min(2, defenders)
const fights = Math.min(a, d)
let p = 0
for (const [attackerLoss, chance] of OUTCOMES[`${a}v${d}`]) {
p += chance * winChance(attackers - attackerLoss, defenders - (fights - attackerLoss))
}
memo.set(key, p)
return p
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { Game, STARTING_ARMIES } from './game'
import { TERRITORY_IDS, type Territory } from './map'
/** Place every setup army wherever it is legal, so a test can start at turn one. */
function pastSetup(g: Game) {
while (g.phase === 'setup') g.place(g.territoriesOf(g.current)[0])
}
describe('the deal', () => {
it('shares the world out seven each, with twenty armies apiece', () => {
const g = new Game(1)
for (const p of g.players) expect(g.territoriesOf(p.id)).toHaveLength(7)
pastSetup(g)
for (const p of g.players) expect(g.armiesOf(p.id)).toBe(STARTING_ARMIES)
expect(g.phase).toBe('reinforce')
expect(g.current).toBe(g.first)
expect(g.round).toBe(1)
})
it('is the same deal for the same seed', () => {
const a = new Game(42)
const b = new Game(42)
expect(TERRITORY_IDS.map((t) => a.owner[t])).toEqual(TERRITORY_IDS.map((t) => b.owner[t]))
})
})
describe('reinforcements', () => {
it('are a third of your territories, at least three, plus whole continents', () => {
const g = new Game(1)
const p = 0
for (const t of TERRITORY_IDS) g.owner[t] = 1
expect(g.income(p)).toBe(3)
for (const t of ['indonesia', 'new-guinea', 'western-australia', 'eastern-australia'] as Territory[])
g.owner[t] = p
expect(g.income(p)).toBe(3 + 2)
for (const t of TERRITORY_IDS.slice(0, 15)) g.owner[t] = p
// 19 territories, North and South America and Australia.
expect(g.territoriesOf(p)).toHaveLength(19)
expect(g.income(p)).toBe(6 + 5 + 2 + 2)
})
})
describe('a battle', () => {
function staged(attackers: number, defenders: number) {
const g = new Game(3)
pastSetup(g)
for (const t of TERRITORY_IDS) {
g.owner[t] = 1
g.armies[t] = 1
}
g.current = 0
g.owner.brazil = 0
g.armies.brazil = attackers
g.armies['north-africa'] = defenders
g.reinforcements = 0
g.beginAttack()
return g
}
it('refuses what the rules refuse', () => {
const g = staged(5, 2)
expect(() => g.attack('brazil', 'china')).toThrow(/border/)
expect(() => g.attack('peru', 'brazil')).toThrow(/not yours/)
g.armies.brazil = 1
expect(() => g.attack('brazil', 'north-africa')).toThrow(/spare/)
})
it('takes a territory, marches in, and earns a card', () => {
const g = staged(40, 1)
while (g.phase === 'attack') g.attack('brazil', 'north-africa')
expect(g.phase).toBe('occupy')
expect(g.owner['north-africa']).toBe(0)
const o = g.occupation!
expect(o.min).toBe(3)
expect(() => g.occupy(o.min - 1)).toThrow()
g.occupy(o.max)
expect(g.armies.brazil).toBe(1)
g.endAttack()
g.endTurn()
expect(g.players[0].cards).toHaveLength(1)
})
it('hands over the loser\'s cards and forces a trade at six', () => {
const g = staged(40, 1)
for (const t of TERRITORY_IDS) if (t !== 'north-africa' && t !== 'brazil') g.owner[t] = 2
g.players[0].cards = [
{ territory: 'china', mark: 'infantry' },
{ territory: 'peru', mark: 'cavalry' },
{ territory: 'egypt', mark: 'infantry' },
]
g.players[1].cards = [
{ territory: 'japan', mark: 'cavalry' },
{ territory: 'siam', mark: 'cavalry' },
{ territory: 'ural', mark: 'artillery' },
]
while (g.phase === 'attack') g.attack('brazil', 'north-africa')
expect(g.players[1].alive).toBe(false)
g.occupy(3)
expect(g.phase).toBe('reinforce')
expect(g.mustTrade).toBe(true)
expect(() => g.beginAttack()).toThrow(/trade/)
g.trade([0, 1, 5])
expect(g.hand).toHaveLength(3)
g.place('brazil', g.reinforcements)
g.beginAttack()
expect(g.phase).toBe('attack')
})
})
describe('fortifying', () => {
it('moves once, along a chain of your own, and ends the turn', () => {
const g = new Game(5)
pastSetup(g)
for (const t of TERRITORY_IDS) g.owner[t] = 1
const me = g.current
for (const t of ['alaska', 'alberta', 'western-us'] as Territory[]) g.owner[t] = me
g.armies.alaska = 6
g.place('alaska', g.reinforcements)
g.beginAttack()
g.endAttack()
expect(g.fortifyTargets('alaska').sort()).toEqual(['alberta', 'western-us'])
expect(() => g.fortify('alaska', 'ontario', 1)).toThrow()
const before = g.current
g.fortify('alaska', 'western-us', 3)
expect(g.current).not.toBe(before)
})
})
+370
View File
@@ -0,0 +1,370 @@
import { fullDeck, isSet, setValue, type Card } from './cards'
import { roll, type Roll } from './dice'
import {
ADJACENT,
CONTINENTS,
CONTINENT_IDS,
TERRITORY_IDS,
adjacent,
inContinent,
type Continent,
type Territory,
} from './map'
import { makeRng, shuffle, type Rng } from './rng'
/**
* The game, with no interface attached.
*
* Every rule lives here and nowhere else, and every move is checked here
* whether a person or a bot is making it -- so the board can only ever offer
* what this will accept, and a whole game can be played out in a test from
* the deal to the last territory.
*
* It is a plain mutable object rather than a reducer. A game of this runs to
* thousands of dice rolls, and copying forty-two territories on every one of
* them buys nothing the event log does not already give: everything that has
* ever happened is in `events`, in order, and the interface reads that.
*/
export const PLAYER_COUNT = 6
/** The classic allowance for a six-player table. */
export const STARTING_ARMIES = 20
export type Phase = 'setup' | 'reinforce' | 'attack' | 'occupy' | 'fortify' | 'over'
export interface Player {
id: number
human: boolean
alive: boolean
cards: Card[]
}
export type GameEvent =
| { kind: 'deal'; first: number }
| { kind: 'turn'; player: number; round: number; reinforcements: number }
| { kind: 'place'; player: number; territory: Territory; count: number }
| { kind: 'trade'; player: number; cards: Card[]; armies: number; bonus: Territory[] }
| { kind: 'battle'; player: number; from: Territory; to: Territory; defender: number; roll: Roll }
| { kind: 'conquer'; player: number; from: Territory; to: Territory; loser: number }
| { kind: 'occupy'; player: number; from: Territory; to: Territory; count: number }
| { kind: 'eliminate'; player: number; loser: number; cards: number }
| { kind: 'fortify'; player: number; from: Territory; to: Territory; count: number }
| { kind: 'card'; player: number }
| { kind: 'win'; player: number }
export interface Occupation {
from: Territory
to: Territory
min: number
max: number
}
export class Game {
readonly seed: number
readonly players: Player[]
readonly owner = {} as Record<Territory, number>
readonly armies = {} as Record<Territory, number>
readonly events: GameEvent[] = []
phase: Phase = 'setup'
current: number
first: number
round = 0
/** Armies waiting to be placed, in setup or at the start of a turn. */
reinforcements = 0
/** In setup, what each player has still to place. */
readonly toPlace: number[]
/** Sets traded by anybody, which is what prices the next one. */
trades = 0
conquered = false
occupation: Occupation | null = null
winner: number | null = null
/**
* Set when an elimination handed over enough cards to force a trade in the
* middle of an attack: the trade, and placing what it pays, then back to
* attacking.
*/
resume: 'attack' | null = null
private deck: Card[]
private discard: Card[] = []
private readonly rng: Rng
constructor(seed: number, human: number | null = 0) {
this.seed = seed
this.rng = makeRng(seed)
this.players = Array.from({ length: PLAYER_COUNT }, (_, id) => ({
id,
human: id === human,
alive: true,
cards: [],
}))
// Deal the territories round the table, one army on each.
this.first = Math.floor(this.rng() * PLAYER_COUNT)
const dealt = shuffle(this.rng, TERRITORY_IDS)
dealt.forEach((t, i) => {
this.owner[t] = (this.first + i) % PLAYER_COUNT
this.armies[t] = 1
})
this.toPlace = this.players.map((p) => STARTING_ARMIES - this.territoriesOf(p.id).length)
this.current = this.first
this.deck = shuffle(this.rng, fullDeck())
this.events.push({ kind: 'deal', first: this.first })
}
// ------------------------------------------------------------- queries
territoriesOf(player: number): Territory[] {
return TERRITORY_IDS.filter((t) => this.owner[t] === player)
}
armiesOf(player: number): number {
return this.territoriesOf(player).reduce((n, t) => n + this.armies[t], 0)
}
continentsOf(player: number): Continent[] {
return CONTINENT_IDS.filter((c) => inContinent(c).every((t) => this.owner[t] === player))
}
/** A third of your territories, never less than three, and your continents. */
income(player: number): number {
const base = Math.max(3, Math.floor(this.territoriesOf(player).length / 3))
return base + this.continentsOf(player).reduce((n, c) => n + CONTINENTS[c].bonus, 0)
}
get alive(): Player[] {
return this.players.filter((p) => p.alive)
}
get hand(): Card[] {
return this.players[this.current].cards
}
/** Five cards in hand at the start of a turn, or after taking a loser's, and you must trade. */
get mustTrade(): boolean {
return this.phase === 'reinforce' && this.hand.length >= 5
}
get nextSetValue(): number {
return setValue(this.trades)
}
canAttackFrom(t: Territory): boolean {
return (
this.phase === 'attack' &&
this.owner[t] === this.current &&
this.armies[t] >= 2 &&
ADJACENT[t].some((n) => this.owner[n] !== this.current)
)
}
attackTargets(t: Territory): Territory[] {
if (!this.canAttackFrom(t)) return []
return ADJACENT[t].filter((n) => this.owner[n] !== this.current)
}
/** Everywhere reachable from `t` through territories of the same owner. */
connected(t: Territory): Territory[] {
const who = this.owner[t]
const seen = new Set<Territory>([t])
const queue = [t]
while (queue.length) {
const at = queue.shift()!
for (const n of ADJACENT[at]) {
if (this.owner[n] === who && !seen.has(n)) {
seen.add(n)
queue.push(n)
}
}
}
seen.delete(t)
return [...seen]
}
fortifyTargets(t: Territory): Territory[] {
if (this.phase !== 'fortify' || this.owner[t] !== this.current || this.armies[t] < 2) return []
return this.connected(t)
}
// ------------------------------------------------------------ actions
place(t: Territory, count = 1) {
if (this.owner[t] !== this.current) throw new Error(`${t} is not yours to reinforce`)
if (count < 1 || !Number.isInteger(count)) throw new Error(`cannot place ${count}`)
if (this.phase === 'setup') {
if (count !== 1) throw new Error('setup places one army at a time')
this.armies[t] += 1
this.toPlace[this.current]--
this.events.push({ kind: 'place', player: this.current, territory: t, count: 1 })
this.advanceSetup()
return
}
if (this.phase !== 'reinforce') throw new Error(`cannot place during ${this.phase}`)
if (this.mustTrade) throw new Error('trade cards first')
if (count > this.reinforcements) throw new Error(`only ${this.reinforcements} to place`)
this.armies[t] += count
this.reinforcements -= count
this.events.push({ kind: 'place', player: this.current, territory: t, count })
}
/** Hand in three cards, by their positions in the hand. */
trade(indices: readonly number[]) {
if (this.phase !== 'reinforce') throw new Error('cards are traded while reinforcing')
const hand = this.hand
const unique = [...new Set(indices)]
if (unique.length !== 3 || unique.some((i) => i < 0 || i >= hand.length))
throw new Error('a trade is three cards from the hand')
const cards = unique.map((i) => hand[i])
if (!isSet(cards)) throw new Error('those three are not a set')
const armies = setValue(this.trades)
this.trades++
this.reinforcements += armies
// Two extra on one territory you hold that is pictured on the set.
// Only one of them, however many match.
const bonus: Territory[] = []
const pictured = cards.find((c) => c.territory && this.owner[c.territory] === this.current)
if (pictured?.territory) {
this.armies[pictured.territory] += 2
bonus.push(pictured.territory)
}
this.players[this.current].cards = hand.filter((_, i) => !unique.includes(i))
this.discard.push(...cards)
this.events.push({ kind: 'trade', player: this.current, cards, armies, bonus })
}
/** Done placing; on to the fighting (or back to it). */
beginAttack() {
if (this.phase !== 'reinforce') throw new Error(`not reinforcing`)
if (this.mustTrade) throw new Error('trade cards first')
if (this.reinforcements > 0) throw new Error(`${this.reinforcements} armies still to place`)
this.phase = 'attack'
this.resume = null
}
/** One throw of the dice. Returns it, and the territory may change hands. */
attack(from: Territory, to: Territory, dice = 3): Roll {
if (this.phase !== 'attack') throw new Error(`cannot attack during ${this.phase}`)
if (this.owner[from] !== this.current) throw new Error(`${from} is not yours`)
if (this.owner[to] === this.current) throw new Error(`${to} is already yours`)
if (!adjacent(from, to)) throw new Error(`${from} does not border ${to}`)
if (this.armies[from] < 2) throw new Error(`${from} has nobody to spare`)
const attackDice = Math.max(1, Math.min(3, dice, this.armies[from] - 1))
const defendDice = Math.min(2, this.armies[to])
const defender = this.owner[to]
const r = roll(this.rng, attackDice, defendDice)
this.armies[from] -= r.attackerLoss
this.armies[to] -= r.defenderLoss
this.events.push({ kind: 'battle', player: this.current, from, to, defender, roll: r })
if (this.armies[to] === 0) {
this.owner[to] = this.current
this.conquered = true
this.events.push({ kind: 'conquer', player: this.current, from, to, loser: defender })
this.occupation = { from, to, min: attackDice, max: this.armies[from] - 1 }
this.phase = 'occupy'
if (this.territoriesOf(defender).length === 0) {
const loser = this.players[defender]
loser.alive = false
const taken = loser.cards.length
this.players[this.current].cards.push(...loser.cards)
loser.cards = []
this.events.push({ kind: 'eliminate', player: this.current, loser: defender, cards: taken })
}
}
return r
}
/** March into the territory just taken: at least as many as rolled, at most all but one. */
occupy(count: number) {
const o = this.occupation
if (this.phase !== 'occupy' || !o) throw new Error('nothing to occupy')
if (count < o.min || count > o.max) throw new Error(`move between ${o.min} and ${o.max}`)
this.armies[o.from] -= count
this.armies[o.to] = count
this.events.push({ kind: 'occupy', player: this.current, from: o.from, to: o.to, count })
this.occupation = null
this.phase = 'attack'
if (this.territoriesOf(this.current).length === TERRITORY_IDS.length) {
this.winner = this.current
this.phase = 'over'
this.events.push({ kind: 'win', player: this.current })
return
}
if (this.hand.length >= 6) {
this.phase = 'reinforce'
this.resume = 'attack'
this.reinforcements = 0
}
}
endAttack() {
if (this.phase !== 'attack') throw new Error(`not attacking`)
this.phase = 'fortify'
}
/** One move between two of your territories joined by your own, and the turn ends. */
fortify(from: Territory, to: Territory, count: number) {
if (this.phase !== 'fortify') throw new Error(`not fortifying`)
if (!this.fortifyTargets(from).includes(to)) throw new Error(`${from} cannot reach ${to}`)
if (count < 1 || count > this.armies[from] - 1) throw new Error(`cannot move ${count}`)
this.armies[from] -= count
this.armies[to] += count
this.events.push({ kind: 'fortify', player: this.current, from, to, count })
this.endTurn()
}
endTurn() {
if (this.phase !== 'attack' && this.phase !== 'fortify') throw new Error(`cannot end turn during ${this.phase}`)
if (this.conquered) {
if (this.deck.length === 0) {
this.deck = shuffle(this.rng, this.discard)
this.discard = []
}
const card = this.deck.pop()
if (card) {
this.players[this.current].cards.push(card)
this.events.push({ kind: 'card', player: this.current })
}
}
// A round is counted each time play comes back past the seat that went
// first, whether or not that player is still at the table.
let next = this.current
do {
next = (next + 1) % PLAYER_COUNT
if (next === this.first) this.round++
} while (!this.players[next].alive)
this.startTurn(next)
}
// ------------------------------------------------------------ internals
private advanceSetup() {
if (this.toPlace.every((n) => n === 0)) {
this.round = 1
this.startTurn(this.first)
return
}
let next = this.current
do next = (next + 1) % PLAYER_COUNT
while (this.toPlace[next] === 0)
this.current = next
}
private startTurn(player: number) {
this.current = player
this.phase = 'reinforce'
this.resume = null
this.conquered = false
this.reinforcements = this.income(player)
this.events.push({ kind: 'turn', player, round: this.round, reinforcements: this.reinforcements })
}
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import board from './board.json'
import { ADJACENT, BORDERS, CONTINENTS, CONTINENT_IDS, LINK_LIST, TERRITORY_IDS, inContinent } from './map'
describe('the board', () => {
it('has the classic forty-two territories in six continents', () => {
expect(TERRITORY_IDS).toHaveLength(42)
expect(CONTINENT_IDS.map((c) => inContinent(c).length)).toEqual([9, 4, 7, 6, 12, 4])
expect(CONTINENT_IDS.map((c) => CONTINENTS[c].bonus)).toEqual([5, 2, 5, 3, 7, 2])
})
it('has the classic eighty-three links, none twice', () => {
expect(LINK_LIST).toHaveLength(83)
const keys = LINK_LIST.map(([a, b]) => [a, b].sort().join('|'))
expect(new Set(keys).size).toBe(83)
})
it('is symmetric, and nothing borders itself', () => {
for (const t of TERRITORY_IDS) {
expect(ADJACENT[t]).not.toContain(t)
for (const n of ADJACENT[t]) expect(ADJACENT[n]).toContain(t)
}
})
it('is one connected world', () => {
const seen = new Set([TERRITORY_IDS[0]])
const queue = [TERRITORY_IDS[0]]
while (queue.length) {
for (const n of ADJACENT[queue.shift()!]) {
if (seen.has(n)) continue
seen.add(n)
queue.push(n)
}
}
expect(seen.size).toBe(42)
})
// The doors are the strategy. If one of these moves, the game has changed.
it('keeps the doors where every player expects them', () => {
expect([...BORDERS['australia']]).toEqual(['indonesia'])
expect([...BORDERS['south-america']].sort()).toEqual(['brazil', 'venezuela'])
expect([...BORDERS['north-america']].sort()).toEqual(['alaska', 'central-america', 'greenland'])
expect([...BORDERS['africa']].sort()).toEqual(['east-africa', 'egypt', 'north-africa'])
expect([...BORDERS['europe']].sort()).toEqual(['iceland', 'southern-europe', 'ukraine', 'western-europe'])
expect([...BORDERS['asia']].sort()).toEqual(['afghanistan', 'kamchatka', 'middle-east', 'siam', 'ural'])
})
})
describe('the drawing', () => {
const drawn = board.territories as Record<string, { d: string; label: number[] }>
it('draws every territory and nothing else', () => {
expect(Object.keys(drawn).sort()).toEqual([...TERRITORY_IDS].sort())
})
it('can draw a lane for every link that is not a shared border', () => {
const touching = new Set(board.touching.map((p) => [...p].sort().join('|')))
const near = board.near as Record<string, number[]>
for (const [a, b] of LINK_LIST) {
const key = [a, b].sort().join('|')
if (touching.has(key) || key === 'alaska|kamchatka') continue
expect(near[key], key).toBeDefined()
}
})
})
+201
View File
@@ -0,0 +1,201 @@
/**
* The board as the rules see it: forty-two territories in six continents, and
* which of them border which.
*
* The topology is the classic one, link for link. It is a fact about a
* published game and free to use, and it is also the part players know by
* heart: that Australia has one door, that South America has two, that Asia
* has nowhere to hide. The shapes the board draws come from real geography in
* `board.json` and only approximate this -- where they disagree, the rules
* here are the ones that count, and the board says so by drawing a lane across
* the water or closing a border that happens to touch.
*/
export const CONTINENTS = {
'north-america': { name: 'North America', bonus: 5 },
'south-america': { name: 'South America', bonus: 2 },
europe: { name: 'Europe', bonus: 5 },
africa: { name: 'Africa', bonus: 3 },
asia: { name: 'Asia', bonus: 7 },
australia: { name: 'Australia', bonus: 2 },
} as const
export type Continent = keyof typeof CONTINENTS
export const TERRITORIES = {
alaska: { name: 'Alaska', continent: 'north-america' },
'northwest-territory': { name: 'Northwest Territory', continent: 'north-america' },
greenland: { name: 'Greenland', continent: 'north-america' },
alberta: { name: 'Alberta', continent: 'north-america' },
ontario: { name: 'Ontario', continent: 'north-america' },
quebec: { name: 'Quebec', continent: 'north-america' },
'western-us': { name: 'Western United States', continent: 'north-america' },
'eastern-us': { name: 'Eastern United States', continent: 'north-america' },
'central-america': { name: 'Central America', continent: 'north-america' },
venezuela: { name: 'Venezuela', continent: 'south-america' },
peru: { name: 'Peru', continent: 'south-america' },
brazil: { name: 'Brazil', continent: 'south-america' },
argentina: { name: 'Argentina', continent: 'south-america' },
iceland: { name: 'Iceland', continent: 'europe' },
'great-britain': { name: 'Great Britain', continent: 'europe' },
scandinavia: { name: 'Scandinavia', continent: 'europe' },
'northern-europe': { name: 'Northern Europe', continent: 'europe' },
'western-europe': { name: 'Western Europe', continent: 'europe' },
'southern-europe': { name: 'Southern Europe', continent: 'europe' },
ukraine: { name: 'Ukraine', continent: 'europe' },
'north-africa': { name: 'North Africa', continent: 'africa' },
egypt: { name: 'Egypt', continent: 'africa' },
'east-africa': { name: 'East Africa', continent: 'africa' },
congo: { name: 'Congo', continent: 'africa' },
'south-africa': { name: 'South Africa', continent: 'africa' },
madagascar: { name: 'Madagascar', continent: 'africa' },
ural: { name: 'Ural', continent: 'asia' },
siberia: { name: 'Siberia', continent: 'asia' },
yakutsk: { name: 'Yakutsk', continent: 'asia' },
kamchatka: { name: 'Kamchatka', continent: 'asia' },
irkutsk: { name: 'Irkutsk', continent: 'asia' },
mongolia: { name: 'Mongolia', continent: 'asia' },
japan: { name: 'Japan', continent: 'asia' },
afghanistan: { name: 'Afghanistan', continent: 'asia' },
china: { name: 'China', continent: 'asia' },
'middle-east': { name: 'Middle East', continent: 'asia' },
india: { name: 'India', continent: 'asia' },
siam: { name: 'Siam', continent: 'asia' },
indonesia: { name: 'Indonesia', continent: 'australia' },
'new-guinea': { name: 'New Guinea', continent: 'australia' },
'western-australia': { name: 'Western Australia', continent: 'australia' },
'eastern-australia': { name: 'Eastern Australia', continent: 'australia' },
} as const satisfies Record<string, { name: string; continent: Continent }>
export type Territory = keyof typeof TERRITORIES
export const TERRITORY_IDS = Object.keys(TERRITORIES) as Territory[]
/** Each link once, in the order a player would read the board. */
const LINKS: readonly (readonly [Territory, Territory])[] = [
['alaska', 'northwest-territory'],
['alaska', 'alberta'],
['alaska', 'kamchatka'],
['northwest-territory', 'alberta'],
['northwest-territory', 'ontario'],
['northwest-territory', 'greenland'],
['greenland', 'ontario'],
['greenland', 'quebec'],
['greenland', 'iceland'],
['alberta', 'ontario'],
['alberta', 'western-us'],
['ontario', 'western-us'],
['ontario', 'eastern-us'],
['ontario', 'quebec'],
['quebec', 'eastern-us'],
['western-us', 'eastern-us'],
['western-us', 'central-america'],
['eastern-us', 'central-america'],
['central-america', 'venezuela'],
['venezuela', 'peru'],
['venezuela', 'brazil'],
['peru', 'brazil'],
['peru', 'argentina'],
['brazil', 'argentina'],
['brazil', 'north-africa'],
['iceland', 'great-britain'],
['iceland', 'scandinavia'],
['great-britain', 'scandinavia'],
['great-britain', 'northern-europe'],
['great-britain', 'western-europe'],
['scandinavia', 'northern-europe'],
['scandinavia', 'ukraine'],
['northern-europe', 'ukraine'],
['northern-europe', 'southern-europe'],
['northern-europe', 'western-europe'],
['western-europe', 'southern-europe'],
['western-europe', 'north-africa'],
['southern-europe', 'ukraine'],
['southern-europe', 'middle-east'],
['southern-europe', 'egypt'],
['southern-europe', 'north-africa'],
['ukraine', 'middle-east'],
['ukraine', 'afghanistan'],
['ukraine', 'ural'],
['north-africa', 'egypt'],
['north-africa', 'east-africa'],
['north-africa', 'congo'],
['egypt', 'middle-east'],
['egypt', 'east-africa'],
['east-africa', 'congo'],
['east-africa', 'south-africa'],
['east-africa', 'madagascar'],
['east-africa', 'middle-east'],
['congo', 'south-africa'],
['south-africa', 'madagascar'],
['middle-east', 'afghanistan'],
['middle-east', 'india'],
['afghanistan', 'ural'],
['afghanistan', 'china'],
['afghanistan', 'india'],
['ural', 'siberia'],
['ural', 'china'],
['siberia', 'yakutsk'],
['siberia', 'irkutsk'],
['siberia', 'mongolia'],
['siberia', 'china'],
['yakutsk', 'kamchatka'],
['yakutsk', 'irkutsk'],
['kamchatka', 'irkutsk'],
['kamchatka', 'mongolia'],
['kamchatka', 'japan'],
['irkutsk', 'mongolia'],
['mongolia', 'japan'],
['mongolia', 'china'],
['china', 'siam'],
['china', 'india'],
['india', 'siam'],
['siam', 'indonesia'],
['indonesia', 'new-guinea'],
['indonesia', 'western-australia'],
['new-guinea', 'western-australia'],
['new-guinea', 'eastern-australia'],
['western-australia', 'eastern-australia'],
]
export const LINK_LIST = LINKS
export const ADJACENT: Record<Territory, readonly Territory[]> = (() => {
const adj = Object.fromEntries(TERRITORY_IDS.map((t) => [t, [] as Territory[]])) as Record<
Territory,
Territory[]
>
for (const [a, b] of LINKS) {
adj[a].push(b)
adj[b].push(a)
}
return adj
})()
export const adjacent = (a: Territory, b: Territory) => ADJACENT[a].includes(b)
export const inContinent = (c: Continent) =>
TERRITORY_IDS.filter((t) => TERRITORIES[t].continent === c)
export const CONTINENT_IDS = Object.keys(CONTINENTS) as Continent[]
/**
* The territories through which a continent can be entered: those with a
* neighbor outside it. Holding a continent means holding these.
*/
export const BORDERS: Record<Continent, readonly Territory[]> = Object.fromEntries(
CONTINENT_IDS.map((c) => [
c,
inContinent(c).filter((t) => ADJACENT[t].some((n) => TERRITORIES[n].continent !== c)),
]),
) as Record<Continent, Territory[]>
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { apply, decide, leanings } from './bot'
import { Game, PLAYER_COUNT } from './game'
/**
* Six bots left alone, to the end.
*
* The question that matters about the bots is not whether each move looks
* sensible but whether a table of them ever finishes. A game that cannot be
* won is not a hard game, it is a broken one -- so these play out in full,
* on thirty seeds, and every one of them has to end in a winner.
*/
export function playOut(seed: number, limit = 20000) {
const g = new Game(seed, null)
const lean = leanings(seed, PLAYER_COUNT)
let actions = 0
while (g.phase !== 'over' && actions < limit) {
apply(g, decide(g, lean[g.current]))
actions++
}
return { g, actions }
}
describe('a table of bots', () => {
it('plays every seed through to a winner', () => {
const rounds: number[] = []
for (let seed = 1; seed <= 30; seed++) {
const { g } = playOut(seed)
expect(g.phase, `seed ${seed}`).toBe('over')
expect(g.winner).not.toBeNull()
expect(g.territoriesOf(g.winner!)).toHaveLength(42)
rounds.push(g.round)
}
const mean = rounds.reduce((a, b) => a + b, 0) / rounds.length
console.log(`rounds: mean ${mean.toFixed(1)}, min ${Math.min(...rounds)}, max ${Math.max(...rounds)}`)
expect(Math.max(...rounds)).toBeLessThan(80)
})
it('does not always hand it to the same seat', () => {
const winners = new Set<number>()
for (let seed = 1; seed <= 30; seed++) winners.add(playOut(seed).g.winner!)
expect(winners.size).toBeGreaterThan(3)
})
it('replays exactly from the same seed', () => {
const a = playOut(7).g
const b = playOut(7).g
expect(a.events.length).toBe(b.events.length)
expect(a.winner).toBe(b.winner)
})
})
+26
View File
@@ -0,0 +1,26 @@
/**
* mulberry32 — small, fast, seedable PRNG.
* The dice and the deal come from it, and so do the bots' leanings, so a seed
* replays a whole game exactly and a test can throw the same dice twice.
*/
export function makeRng(seed: number) {
let a = seed >>> 0
return function rng(): number {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export type Rng = ReturnType<typeof makeRng>
export function shuffle<T>(rng: Rng, items: readonly T[]): T[] {
const out = [...items]
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1))
;[out[i], out[j]] = [out[j], out[i]]
}
return out
}
+15
View File
@@ -0,0 +1,15 @@
:root {
font-synthesis: none;
-webkit-font-smoothing: antialiased;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: radial-gradient(120% 90% at 50% 0%, #23324a 0%, #16202f 55%, #0d141d 100%);
color: #f2e9d8;
font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)