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:
+337
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' : '.')),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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[]>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
Reference in New Issue
Block a user