The war, out loud

Music, battle sounds and the four endings.

The score is original and says so at length in score.ts: there is a great
deal of music from and about that war, some of it still in copyright
somewhere, and it would be a strange sort of care to license the map
properly and then lift eight bars of somebody's march. What is borrowed is
the idiom, which belongs to nobody -- a minor key, dotted rhythms, low brass
a bar at a time, and a side drum that never stops.

Three pieces, and which one is playing tells you something. THE_FRONT in
spring, when the armies move and nothing is decided. THE_PUSH in autumn, at
a hundred and thirty-two, when centres change hands and the year is counted.
ARMISTICE when it is over, whoever won.

The sounds are chosen in the rules rather than fired from the interface, for
the same reason the orders are validated there: what a turn sounded like is
a question with a right answer, and a right answer can be tested. src/game/
sound.ts turns orders and an outcome into cues and nothing in it touches an
AudioContext. The interesting cases are the quiet ones -- a turn where
everybody walks into empty country makes no sound at all, and getting that
wrong would be the same as having no guns, because guns on every turn are
just weather.

Each kind plays once however many battles there were. Four land battles are
not four times as loud as one, they are mud, and the player learns nothing
from mud.

A landing is ground and two fleets over a coast are naval, which is the sort
of distinction that is only worth making because the rules already know
enough to make it. An escort sounds only for a convoy that actually carried
somebody: a held order succeeds, so asking whether the army's order worked
is the wrong question -- ask whether it was ordered to make the crossing and
whether it arrived.

Four endings rather than two, because losing has more than one shape. Being
eliminated in 1904 and watching Russia take an eighteenth centre in 1911 are
both defeats and they do not feel remotely alike.

The buses open at whatever the toggle already says rather than at full: a
one-shot opens the context itself, so a game started with the sound off
would otherwise have made exactly one noise, the first one, before anything
got round to muting it.
This commit is contained in:
2026-09-09 09:15:34 -07:00
parent 0208a10da6
commit 9c6efb3d85
9 changed files with 1522 additions and 19 deletions
+24 -2
View File
@@ -17,7 +17,15 @@
padding: clamp(10px, 2vw, 22px); padding: clamp(10px, 2vw, 22px);
} }
header { grid-area: head; } /* The title on the left, the sound on the right, the state of play under
both -- so the toggle is findable without taking a line of its own. */
header {
grid-area: head;
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
column-gap: 12px;
}
h1 { h1 {
margin: 0; margin: 0;
@@ -29,7 +37,21 @@ h1 {
paint-order: stroke fill; paint-order: stroke fill;
} }
header p { margin: 2px 0 0; font-size: 0.9rem; } header p { grid-column: 1 / -1; margin: 2px 0 0; font-size: 0.9rem; }
.sound {
font: inherit;
font-weight: 700;
font-size: 0.85rem;
color: #f2e9d8;
background: #2c3b57;
border: 2px solid #2b2318;
border-radius: 6px;
padding: 3px 10px;
cursor: pointer;
}
.sound[aria-pressed='false'] { color: #8c98ab; background: #1b2437; }
header select { header select {
font: inherit; font: inherit;
+79 -3
View File
@@ -1,4 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { playCues, playEnding } from './audio/play'
import { ARMISTICE, THE_FRONT, THE_PUSH } from './audio/score'
import { synth } from './audio/synth'
import { Board, COLOURS, POWER_NAMES } from './components/Board' import { Board, COLOURS, POWER_NAMES } from './components/Board'
import { BuildPanel, RetreatPanel } from './components/AdjustPanel' import { BuildPanel, RetreatPanel } from './components/AdjustPanel'
import { OrderPanel, type Step } from './components/OrderPanel' import { OrderPanel, type Step } from './components/OrderPanel'
@@ -18,6 +21,7 @@ import { reachableFrom } from './game/layout'
import { POWERS, PROVINCES, base, type Power } from './game/map' import { POWERS, PROVINCES, base, type Power } from './game/map'
import { canStep, validate, type Order, type Unit } from './game/orders' import { canStep, validate, type Order, type Unit } from './game/orders'
import type { Proposal } from './game/press' import type { Proposal } from './game/press'
import { endingSounded, mergeCues, type Cue } from './game/sound'
import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn' import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn'
import './App.css' import './App.css'
@@ -36,6 +40,48 @@ export default function App() {
const [orders, setOrders] = useState<Map<string, Order>>(new Map()) const [orders, setOrders] = useState<Map<string, Order>>(new Map())
const [retreats, setRetreats] = useState<Map<string, RetreatOrder>>(new Map()) const [retreats, setRetreats] = useState<Map<string, RetreatOrder>>(new Map())
const [builds, setBuilds] = useState<AdjustOrder[]>([]) const [builds, setBuilds] = useState<AdjustOrder[]>([])
const [sound, setSound] = useState(true)
// ------------------------------------------------------------ audio glue
/*
* A browser will not make a noise until somebody has touched the page, so
* the context is opened on the first click rather than on load. Everything
* before that is silent whatever the toggle says, which is the rule and
* not a bug.
*/
const started = useRef(false)
/*
* Which piece is playing is not decoration: spring is the march, autumn is
* the same march at a hundred and thirty-two, and autumn is the season
* that counts centres. You can hear what time of year it is.
*/
const tune = game.phase === 'over' ? ARMISTICE : game.season === 'spring' ? THE_FRONT : THE_PUSH
const wake = useCallback(() => {
if (started.current || !sound) return
started.current = true
synth.ensure()
synth.playTune(tune, false)
}, [sound, tune])
useEffect(() => {
synth.setMusic(sound)
synth.setSfx(sound)
}, [sound])
useEffect(() => {
if (started.current) synth.playTune(tune, false)
}, [tune])
/** The ending, once, when there is one. */
const ended = useRef(false)
useEffect(() => {
if (game.phase !== 'over' || ended.current) return
ended.current = true
playEnding(endingSounded(game.winner, game.out, power))
}, [game.phase, game.winner, game.out, power])
/* /*
* The talking happens once per orders phase. Keeping a note of which turn * The talking happens once per orders phase. Keeping a note of which turn
@@ -89,9 +135,14 @@ export default function App() {
const write = useCallback((order: Order) => { const write = useCallback((order: Order) => {
setOrders((prev) => new Map(prev).set(base(order.at), order)) setOrders((prev) => new Map(prev).set(base(order.at), order))
setStep({ kind: 'idle' }) setStep({ kind: 'idle' })
if (synth.sfxOn) synth.written()
}, []) }, [])
const click = (province: string) => { const click = (province: string) => {
wake()
// Guarded rather than left to the muted bus, so a game played with the
// sound off never opens an audio context at all.
if (synth.sfxOn) synth.tap()
const here = units.get(province) const here = units.get(province)
if (step.kind === 'idle' || !offering.has(province)) { if (step.kind === 'idle' || !offering.has(province)) {
if (here?.power === power) setStep({ kind: 'move', at: province }) if (here?.power === power) setStep({ kind: 'move', at: province })
@@ -123,6 +174,7 @@ export default function App() {
/** Yes or no to somebody's approach. Neither answer binds anybody. */ /** Yes or no to somebody's approach. Neither answer binds anybody. */
const answer = (overture: Overture, yes: boolean) => { const answer = (overture: Overture, yes: boolean) => {
if (synth.sfxOn) synth.telegraph(yes)
setAsked((prev) => prev.filter((o) => o.proposal.id !== overture.proposal.id)) setAsked((prev) => prev.filter((o) => o.proposal.id !== overture.proposal.id))
if (yes) setGame((g) => ({ ...g, agreements: [...g.agreements, overture.proposal] })) if (yes) setGame((g) => ({ ...g, agreements: [...g.agreements, overture.proposal] }))
} }
@@ -138,6 +190,7 @@ export default function App() {
} }
const theirs = { power: to, ledger: game.ledger, agreements: game.agreements } const theirs = { power: to, ledger: game.ledger, agreements: game.agreements }
const reply = consider(pos, theirs, proposal, turnOf(game)) const reply = consider(pos, theirs, proposal, turnOf(game))
if (synth.sfxOn) synth.telegraph(reply.reply === 'accept')
if (reply.reply === 'accept') { if (reply.reply === 'accept') {
setGame((g) => ({ ...g, agreements: [...g.agreements, proposal] })) setGame((g) => ({ ...g, agreements: [...g.agreements, proposal] }))
} }
@@ -155,27 +208,38 @@ export default function App() {
const advance = useCallback( const advance = useCallback(
(from: Game): Game => { (from: Game): Game => {
let g = from let g = from
/*
* Each phase reports its own sounds, so a submission that runs through
* three of them would otherwise arrive with only the last one's. They
* are collected here and played together.
*/
let heard: Cue[] = g.sounds
for (;;) { for (;;) {
if (g.phase === 'retreats') { if (g.phase === 'retreats') {
const mineBeaten = [...(g.outcome?.dislodged.values() ?? [])].some( const mineBeaten = [...(g.outcome?.dislodged.values() ?? [])].some(
(d) => d.unit.power === power, (d) => d.unit.power === power,
) )
if (mineBeaten) return g if (mineBeaten) break
g = resolveRetreats(g, power, []) g = resolveRetreats(g, power, [])
heard = mergeCues(heard, g.sounds)
continue continue
} }
if (g.phase === 'builds') { if (g.phase === 'builds') {
if (adjustmentFor(g.own, g.board, power) !== 0) return g if (adjustmentFor(g.own, g.board, power) !== 0) break
g = resolveBuilds(g, power, []) g = resolveBuilds(g, power, [])
heard = mergeCues(heard, g.sounds)
continue continue
} }
return g break
} }
playCues(heard)
return g
}, },
[power], [power],
) )
const submit = () => { const submit = () => {
wake()
setOrders(new Map()) setOrders(new Map())
setStep({ kind: 'idle' }) setStep({ kind: 'idle' })
setAsked([]) setAsked([])
@@ -199,6 +263,18 @@ export default function App() {
<div className="app"> <div className="app">
<header> <header>
<h1>Great Powers</h1> <h1>Great Powers</h1>
<button
type="button"
className="sound"
aria-pressed={sound}
onClick={() => {
const next = !sound
setSound(next)
if (next) wake()
}}
>
{sound ? 'Sound on' : 'Sound off'}
</button>
<p className="dim"> <p className="dim">
{game.phase === 'over' {game.phase === 'over'
? game.winner ? game.winner
+35
View File
@@ -0,0 +1,35 @@
import type { Cue, Ending } from '../game/sound'
import { synth } from './synth'
/**
* Cues to actual noise, spaced out.
*
* A turn's sounds all land at once if you let them, and three of them at once
* is one indistinct crash. So each gets its own moment. The gaps are the
* lengths of the sounds themselves, near enough -- the guns run about a
* second, and the escort a little longer.
*/
const GAP = 900
export function playCues(cues: readonly Cue[]) {
if (!synth.sfxOn) return
for (const [i, cue] of cues.entries()) {
window.setTimeout(() => {
if (!synth.sfxOn) return
if (cue === 'ground') synth.ground()
else if (cue === 'naval') synth.naval()
else if (cue === 'escort') synth.escort()
else if (cue === 'retreat') synth.retreat()
else if (cue === 'disband') synth.disband()
else synth.build()
}, i * GAP)
}
}
export function playEnding(ending: Ending) {
if (!synth.sfxOn) return
if (ending === 'victory') synth.victory()
else if (ending === 'defeat') synth.defeat()
else if (ending === 'eliminated') synth.eliminated()
else synth.armistice()
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { ARMISTICE, THE_FRONT, THE_PUSH } from './score'
import { noteToFreq, type Tune } from './synth'
/**
* The music cannot be listened to by a test, but it can be proved to be
* playable, which is the half that fails silently.
*
* A mistyped note name does not throw: `noteToFreq` returns 0, the oscillator
* is set to 0 Hz, and that voice is simply not there for the rest of the
* piece. You would have to notice a missing inner part by ear, in a tune you
* have heard forty times while testing something else. So every step of every
* track is checked here instead.
*
* The key is checked too, which is not pedantry: three pieces meant to be the
* same war have to agree about what note the war is in, and a B natural typed
* for a B flat would be a wrong note nobody would trace back to a keyboard.
*/
const KIT = new Set(['K', 'S', 'C', 'H', 'O'])
const TUNES: [string, Tune][] = [
['THE_FRONT', THE_FRONT],
['THE_PUSH', THE_PUSH],
['ARMISTICE', ARMISTICE],
]
/** D minor, with the raised leading note and the picardy third allowed. */
const IN_KEY = new Set(['D', 'E', 'F', 'G', 'A', 'A#', 'C', 'C#', 'F#'])
describe.each(TUNES)('%s', (_name, tune) => {
it('names a note the synth can find, on every step of every track', () => {
for (const track of tune.tracks) {
for (const step of track.notes) {
if (step === '.' || step === '=') continue
if (track.wave === 'noise') {
expect(KIT).toContain(step)
continue
}
// Chords are stacked with slashes; every voice in one has to parse.
for (const note of step.split('/')) {
expect(noteToFreq(note), `${note} in ${step}`).toBeGreaterThan(0)
}
}
}
})
it('stays in D minor', () => {
for (const track of tune.tracks) {
if (track.wave === 'noise') continue
for (const step of track.notes) {
if (step === '.' || step === '=') continue
for (const note of step.split('/')) {
expect(IN_KEY, note).toContain(note.replace(/-?\d$/, ''))
}
}
}
})
it('is the same length on every track, so the loop does not drift', () => {
const lengths = new Set(tune.tracks.map((t) => t.notes.length))
expect(lengths.size).toBe(1)
})
it('starts on a note rather than a tie', () => {
// '=' sustains the previous step, and there is no previous step at zero.
for (const track of tune.tracks) expect(track.notes[0]).not.toBe('=')
})
})
it('marches faster in autumn than in spring', () => {
// Not decoration: which piece is playing is how the year is counted.
expect(THE_PUSH.bpm).toBeGreaterThan(THE_FRONT.bpm)
expect(ARMISTICE.bpm).toBeLessThan(THE_FRONT.bpm)
})
+267
View File
@@ -0,0 +1,267 @@
import type { Tune } from './synth'
/**
* The score, and what it is and is not.
*
* It is not a quotation. There is a great deal of music from and about that
* war and a fair amount of it is still in copyright somewhere, and it would
* be a strange sort of care to build the map from a licensed source, credit
* it properly, and then lift eight bars of somebody's march.
*
* What it *is* is the idiom, which belongs to nobody: a minor key, dotted
* rhythms, low brass moving in whole bars, and a side drum that never stops.
* That is how music has meant "an army is moving" since long before 1914 --
* it is Beethoven's funeral marches before it is anybody's, and the dominant
* chord borrowed from the harmonic minor, which does most of the work here,
* is older than that again. Nobody owns a raised leading note.
*
* D minor throughout, so the three pieces are the same war. The instruments
* are the six voices the sibling games use, leant on differently:
*
* - **brass** is a detuned saw with the filter closing across each note;
* - **the band** is a quiet pulse-50 holding actual chords, one a bar;
* - **the bass** is a triangle, low, doubling the root;
* - **the drums** are the kit's kick as a bass drum and its snare as a
* side drum, on separate tracks because they play at once.
*
* There are three, and which one you are hearing tells you something:
*
* - `THE_FRONT` in spring, when the armies move and nothing is decided;
* - `THE_PUSH` in autumn, when centres change hands and the year is
* counted -- same key, twice the pulse, and the drum never leaves;
* - `ARMISTICE` when it is over, whoever won.
*/
const bar = (...steps: string[]) => steps
/** A chord held for a whole bar. */
const held = (chord: string) => [chord, '=', '=', '=', '=', '=', '=', '=']
/** The bass, doing the same. */
const pedal = held
const BRASS = {
wave: 'saw',
gain: 0.12,
gate: 0.94,
detune: 6,
filter: { from: 1600, to: 850, q: 1.3 },
} as const
const BAND = { wave: 'pulse50', gain: 0.045, gate: 1 } as const
const BASS = { wave: 'triangle', gain: 0.24, gate: 1 } as const
/**
* Spring.
*
* Eight bars. It opens on the dotted figure that is the whole march -- a
* long D, a short E, a long F -- and climbs that same shape twice, the
* second time from a fourth higher so it can reach the top D and stop.
*
* The third bar is where it stops being a parade: the band goes to A major
* rather than A minor, which is a note that does not belong to the key and
* is the reason the bar sounds like a warning.
*/
export const THE_FRONT: Tune = {
bpm: 100,
stepsPerBeat: 2,
tracks: [
{
...BRASS,
notes: [
...bar('D4', '=', '=', 'E4', 'F4', '=', '=', '='),
...bar('E4', '=', '=', 'F4', 'G4', '=', '=', '='),
...bar('A4', '=', '=', '=', 'G4', '=', 'F4', '='),
...bar('E4', '=', '=', '=', '=', '=', '.', '.'),
...bar('A4', '=', '=', 'A#4', 'A4', '=', '=', '='),
...bar('G4', '=', '=', 'A4', 'A#4', '=', '=', '='),
...bar('C5', '=', '=', '=', 'A#4', '=', 'A4', '='),
...bar('D5', '=', '=', '=', '=', '=', '=', '.'),
],
},
{
...BAND,
notes: [
...held('D3/F3/A3'),
...held('D3/F3/A3'),
...held('A2/C#3/E3'),
...held('D3/F3/A3'),
...held('D3/F3/A3'),
...held('A#2/D3/F3'),
...held('A2/C#3/E3'),
...held('D3/F3/A3'),
],
},
{
...BASS,
notes: [
...pedal('D2'),
...pedal('D2'),
...pedal('A1'),
...pedal('D2'),
...pedal('D2'),
...pedal('A#1'),
...pedal('A1'),
...pedal('D2'),
],
},
{
// The side drum. The same four-bar figure twice, opening out at the
// end of each half -- a drummer marks the turn, he does not vamp.
wave: 'noise',
gain: 0.16,
notes: [
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', 'S', 'S', 'S', 'S', '.', 'S', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'),
],
},
{
// The bass drum, on the tread.
wave: 'noise',
gain: 0.3,
notes: Array.from({ length: 64 }, (_, i) => (i % 4 === 0 ? 'K' : '.')),
},
],
}
/**
* Autumn.
*
* The same key and the same drum, at a hundred and thirty-two, with the bass
* on every quaver instead of holding. Four bars rather than eight, because
* the point of it is that it comes round again too soon.
*
* The melody hammers one note before it moves, which is the cheapest way
* music has of sounding out of patience, and the last bar is the dominant
* seventh left hanging -- so the loop does not settle, it restarts.
*/
export const THE_PUSH: Tune = {
bpm: 132,
stepsPerBeat: 2,
tracks: [
{
...BRASS,
gain: 0.13,
notes: [
...bar('D5', 'D5', 'D5', '=', 'C#5', '=', 'D5', '='),
...bar('F5', '=', 'E5', '=', 'D5', '=', 'C#5', '='),
...bar('D5', 'D5', 'D5', '=', 'E5', '=', 'F5', '='),
...bar('A5', '=', '=', '=', 'G5', '=', 'F5', 'E5'),
],
},
{
...BAND,
gain: 0.05,
notes: [
...held('D3/F3/A3'),
...held('A2/C#3/E3'),
...held('D3/F3/A3'),
...held('A2/C#3/G3'),
],
},
{
...BASS,
gate: 0.6,
notes: [
...bar('D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2'),
...bar('A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1'),
...bar('D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2'),
...bar('A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1'),
],
},
{
wave: 'noise',
gain: 0.17,
notes: [
...bar('S', '.', 'S', '.', 'S', '.', 'S', 'S'),
...bar('S', '.', 'S', '.', 'S', '.', 'S', 'S'),
...bar('S', '.', 'S', '.', 'S', '.', 'S', 'S'),
...bar('S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'),
],
},
{
wave: 'noise',
gain: 0.32,
notes: Array.from({ length: 32 }, (_, i) => (i % 2 === 0 ? 'K' : '.')),
},
],
}
/**
* The end of it, whoever won.
*
* Eight bars at sixty, one line over the band, and no drum after the fourth
* bar -- which is the sound the piece is actually about. It turns to D major
* in the seventh, which is a device four hundred years old and still the
* only way music has of saying that something ended without saying it ended
* well.
*/
export const ARMISTICE: Tune = {
bpm: 60,
stepsPerBeat: 2,
tracks: [
{
...BRASS,
gain: 0.11,
filter: { from: 1200, to: 700, q: 1.1 },
notes: [
...bar('A4', '=', '=', '=', 'G4', '=', 'F4', '='),
...bar('E4', '=', '=', '=', '=', '=', '=', '.'),
...bar('F4', '=', '=', '=', 'G4', '=', 'A4', '='),
...bar('A#4', '=', '=', '=', '=', '=', '=', '.'),
...bar('A4', '=', '=', '=', 'G4', '=', 'F4', '='),
...bar('E4', '=', '=', '=', 'D4', '=', '=', '='),
...bar('F#4', '=', '=', '=', 'A4', '=', '=', '='),
...bar('D5', '=', '=', '=', '=', '=', '=', '='),
],
},
{
...BAND,
notes: [
...held('D3/F3/A3'),
...held('A2/C#3/E3'),
...held('D3/F3/A3'),
...held('A#2/D3/F3'),
...held('D3/F3/A3'),
...held('A2/C#3/E3'),
...held('D3/F#3/A3'),
...held('D3/F#3/A3'),
],
},
{
...BASS,
gain: 0.2,
notes: [
...pedal('D2'),
...pedal('A1'),
...pedal('D2'),
...pedal('A#1'),
...pedal('D2'),
...pedal('A1'),
...pedal('D2'),
...pedal('D2'),
],
},
{
// The drum walks out. Four bars of it, then thirty-two rests.
wave: 'noise',
gain: 0.22,
notes: [
...bar('K', '.', '.', '.', 'K', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
],
},
],
}
+725
View File
@@ -0,0 +1,725 @@
/**
* 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
* and the starship. Same author, same licence, and deliberately not forked: a
* look-ahead scheduler is not the part of a game worth writing four times.
*
* What is written *for this game* is everything under "sfx", and the score in
* `score.ts`. A war needs guns, and the difference between a field gun and a
* naval gun is most of what makes a map of Europe sound like 1914.
*
* Pulse waves for voices, filtered white noise for percussion, and the
* scheduler so patterns stay in time even when React is busy re-rendering.
*/
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 })
}
/**
* The dial-in, as a schedule rather than a number buried in the audio.
*
* The boot screen prints what is happening while it happens, so both have to
* agree about when each stage starts -- and the only way to keep two clocks in
* step is to have one clock. All values are seconds from the moment of DIAL.
*/
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)
}
/** An order written down. Pencil on a map board. */
written() {
this.seq([['B4', 0.045], ['E5', 0.08]], 'triangle', 0.14)
}
/** An order the rules will not take. */
reject() {
this.seq([['A3', 0.09], ['D#3', 0.18]], 'saw', 0.16)
}
/**
* The field telegraph, for the press.
*
* Not Morse -- a rhythm that only *reads* as Morse, because a real letter
* would say something and somebody would eventually read it. A carrier tone
* gated into short and long, which is the whole sound of a key being worked.
*/
telegraph(long = false) {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const pattern = long ? [0.06, 0.14, 0.06, 0.06] : [0.06, 0.06, 0.14]
let t = at
for (const len of pattern) {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = 720
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, t)
env.gain.exponentialRampToValueAtTime(0.075, t + 0.006)
env.gain.setValueAtTime(0.075, t + len - 0.01)
env.gain.exponentialRampToValueAtTime(0.0001, t + len)
osc.connect(env).connect(this.sfxBus)
osc.start(t)
osc.stop(t + len + 0.02)
t += len + 0.05
}
}
// -------------------------------------------------------------- the war
/**
* A field gun, and the shell landing.
*
* The two halves are the sound: 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 collapses into 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 province changing hands on land.
*
* Five guns at uneven spacing, because a barrage that keeps time is a
* drum machine. The offsets are fixed rather than random so the sound is
* the same every turn -- a battle that sounds different each time reads as
* a glitch, not as variety.
*/
ground() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, offset] of [0, 0.13, 0.19, 0.36, 0.47].entries()) {
this.gun(at + offset, 0.22 - i * 0.02, 90 - i * 6)
}
// Rifle fire under it: noise chopped fast enough to lose its pitch.
for (let i = 0; i < 14; i++) {
const t = at + 0.08 + i * 0.043
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
src.playbackRate.value = 2
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 2200
bp.Q.value = 0.8
const env = ctx.createGain()
env.gain.setValueAtTime(0.05, t)
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.03)
src.connect(bp).connect(env).connect(this.sfxBus)
src.start(t)
src.stop(t + 0.04)
}
}
/**
* A fight at sea.
*
* The same guns, bigger and slower, and no rifles -- there is nobody
* within a mile to fire one. What replaces them is the sea: filtered noise
* held under the whole thing, which is what keeps this from being the land
* battle at a lower pitch.
*/
naval() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const swell = ctx.createBufferSource()
swell.buffer = this.noiseBuffer
swell.loop = true
swell.playbackRate.value = 0.3
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = 420
lp.Q.value = 0.7
const senv = ctx.createGain()
senv.gain.setValueAtTime(0.0001, at)
senv.gain.exponentialRampToValueAtTime(0.12, at + 0.3)
senv.gain.setValueAtTime(0.12, at + 0.9)
senv.gain.exponentialRampToValueAtTime(0.0001, at + 1.5)
swell.connect(lp).connect(senv).connect(this.sfxBus)
swell.start(at)
swell.stop(at + 1.55)
// A salvo is several barrels at once, which is why they land together.
for (const offset of [0.05, 0.09, 0.13, 0.55, 0.6, 0.66]) {
this.gun(at + offset, 0.2, 62)
}
}
/**
* An escort seeing a convoy across.
*
* A steam whistle answered by a second one further off, over the engine.
* This is the one battle sound in the game that is not a battle: a convoy
* that gets through is a quiet crossing, and it should sound like relief.
*/
escort() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, f] of [330, 246].entries()) {
const t = at + i * 0.34
// A whistle is two close pitches beating against each other.
for (const detune of [-14, 12]) {
const osc = ctx.createOscillator()
osc.type = 'sawtooth'
osc.frequency.value = f
osc.detune.value = detune
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = 1300
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, t)
env.gain.exponentialRampToValueAtTime(0.08 - i * 0.03, t + 0.08)
env.gain.setValueAtTime(0.08 - i * 0.03, t + 0.3)
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.55)
osc.connect(lp).connect(env).connect(this.sfxBus)
osc.start(t)
osc.stop(t + 0.6)
}
}
const engine = ctx.createBufferSource()
engine.buffer = this.noiseBuffer
engine.playbackRate.value = 0.25
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = 260
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(0.09, at + 0.2)
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.1)
engine.connect(lp).connect(env).connect(this.sfxBus)
engine.start(at)
engine.stop(at + 1.2)
}
/**
* Falling back.
*
* A bugle call down rather than up, and a drum walking away behind it.
* The interval matters: the same three notes rising would be a charge, and
* that is the entire difference between the two sounds.
*/
retreat() {
this.seq([['G4', 0.16], ['E4', 0.16], ['C4', 0.36]], 'saw', 0.13)
const ctx = this.ensure()
const at = ctx.currentTime + 0.02
for (const [i, offset] of [0, 0.3, 0.6, 0.9].entries()) {
this.drum(this.sfxBus, 'K', at + offset, 0.28 - i * 0.05)
}
}
/** A unit lost for good: no bugle, just the drum stopping. */
disband() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
this.drum(this.sfxBus, 'K', at, 0.3)
this.seq([['C4', 0.18], ['B3', 0.5]], 'triangle', 0.12)
}
/**
* A unit raised over the winter.
*
* A yard, not a battle: rivets, then the ship or the column moving off.
* Three hammer blows on metal and a rising tone under them.
*/
build() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, offset] of [0, 0.16, 0.32].entries()) {
const t = at + offset
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
src.playbackRate.value = 1.6
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 3200 + i * 400
bp.Q.value = 6
const env = ctx.createGain()
env.gain.setValueAtTime(0.14, t)
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.16)
src.connect(bp).connect(env).connect(this.sfxBus)
src.start(t)
src.stop(t + 0.18)
}
const rise = ctx.createOscillator()
rise.type = 'triangle'
rise.frequency.setValueAtTime(98, at + 0.3)
rise.frequency.exponentialRampToValueAtTime(196, at + 0.85)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at + 0.3)
env.gain.exponentialRampToValueAtTime(0.14, at + 0.45)
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.95)
rise.connect(env).connect(this.sfxBus)
rise.start(at + 0.3)
rise.stop(at + 1)
}
// ---------------------------------------------------------- the endings
/**
* Eighteen centres.
*
* Bells, and the guns firing with nothing to hit. A victory in 1918 was
* church bells before it was anything else, and this is the only sound in
* the game allowed to be straightforwardly happy.
*/
victory() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, note] of ['D5', 'A4', 'F#4', 'D4', 'A4', 'D5'].entries()) {
const t = at + i * 0.42
const f = noteToFreq(note)
// A bell is a struck partial well above the note, decaying fast.
for (const [mult, gain, dur] of [
[1, 0.13, 2.6],
[2.76, 0.05, 1.1],
[5.4, 0.025, 0.5],
] as const) {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = f * mult
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, t)
env.gain.exponentialRampToValueAtTime(gain, t + 0.01)
env.gain.exponentialRampToValueAtTime(0.0001, t + dur)
osc.connect(env).connect(this.sfxBus)
osc.start(t)
osc.stop(t + dur + 0.05)
}
}
for (const offset of [0.6, 1.4, 2.1]) this.gun(at + offset, 0.12, 80)
}
/**
* Somebody else's eighteen.
*
* The same bells, in a minor key, ringing for a capital that is not yours.
* Reusing the victory sound rather than writing a second one is the point:
* a solo sounds the same from the outside, and you only find out which one
* it was by looking at the board.
*/
defeat() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, note] of ['D4', 'F4', 'A3', 'D3'].entries()) {
const t = at + i * 0.5
const f = noteToFreq(note)
for (const [mult, gain, dur] of [
[1, 0.13, 3],
[2.76, 0.04, 1.2],
] as const) {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = f * mult
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, t)
env.gain.exponentialRampToValueAtTime(gain, t + 0.01)
env.gain.exponentialRampToValueAtTime(0.0001, t + dur)
osc.connect(env).connect(this.sfxBus)
osc.start(t)
osc.stop(t + dur + 0.05)
}
}
}
/** Your last centre. A single low bell and nothing after it. */
eliminated() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [mult, gain, dur] of [
[1, 0.16, 4],
[2.76, 0.05, 1.6],
] as const) {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.value = noteToFreq('D2') * 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 armistice.
*
* A draw is not a defeat and it is certainly not a victory, so it gets the
* one thing neither of those has: the guns stopping. A last salvo, and
* then a chord that resolves and simply stays there.
*/
armistice() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
this.gun(at, 0.18, 70)
for (const note of ['D3', 'A3', 'D4', 'F#4']) {
const osc = ctx.createOscillator()
osc.type = 'triangle'
osc.frequency.value = noteToFreq(note)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at + 0.8)
env.gain.exponentialRampToValueAtTime(0.07, at + 1.4)
env.gain.setValueAtTime(0.07, at + 3)
env.gain.exponentialRampToValueAtTime(0.0001, at + 4.5)
osc.connect(env).connect(this.sfxBus)
osc.start(at + 0.8)
osc.stop(at + 4.6)
}
}
}
export const synth = new Synth()
+27 -14
View File
@@ -26,6 +26,7 @@ import {
type Ownership, type Ownership,
type RetreatOrder, type RetreatOrder,
} from './turn' } from './turn'
import { buildsSounded, movesSounded, retreatsSounded, type Cue } from './sound'
/** /**
* The year, and the loop it goes round. * The year, and the loop it goes round.
@@ -72,6 +73,12 @@ export interface Game {
agreements: Agreement[] agreements: Agreement[]
/** Everything that happened, newest last. */ /** Everything that happened, newest last. */
log: string[] log: string[]
/**
* How the last phase sounded, which is a fact about the turn rather than
* about the interface -- so it is worked out here, where the orders are,
* and tested like anything else. See `sound.ts`.
*/
sounds: Cue[]
/** Set while retreats are outstanding. */ /** Set while retreats are outstanding. */
outcome: Outcome | null outcome: Outcome | null
winner: Power | null winner: Power | null
@@ -99,6 +106,7 @@ export function newGame(): Game {
ledger: emptyLedger(), ledger: emptyLedger(),
agreements: [], agreements: [],
log: ['Spring 1901. Nobody has said anything yet.'], log: ['Spring 1901. Nobody has said anything yet.'],
sounds: [],
outcome: null, outcome: null,
winner: null, winner: null,
drawn: [], drawn: [],
@@ -205,7 +213,14 @@ export function resolveOrders(
...report(outcome, all), ...report(outcome, all),
] ]
const next: Game = { ...g, board: after, ledger, log, outcome } const next: Game = {
...g,
board: after,
ledger,
log,
outcome,
sounds: movesSounded(g.board, all, outcome),
}
void rng void rng
return outcome.dislodged.size > 0 return outcome.dislodged.size > 0
@@ -240,7 +255,7 @@ export function resolveRetreats(
`${PROVINCES[base(u.at)]!.name}: nowhere to go.`, `${PROVINCES[base(u.at)]!.name}: nowhere to go.`,
), ),
] ]
return afterRetreats({ ...g, board, log, outcome: null }) return afterRetreats({ ...g, board, log, outcome: null, sounds: retreatsSounded(orders) })
} }
/** The winter, or the next season. */ /** The winter, or the next season. */
@@ -307,6 +322,9 @@ export function resolveBuilds(
let board = g.board let board = g.board
const log = [...g.log] const log = [...g.log]
// Everybody's winter, not only the player's: a shipyard in Trieste is
// still a shipyard.
const adjusted: AdjustOrder[] = [...playerAdjust]
for (const power of POWERS) { for (const power of POWERS) {
if (g.out.includes(power)) continue if (g.out.includes(power)) continue
@@ -324,21 +342,15 @@ export function resolveBuilds(
const wanted = buildOptions(g.own, board, power) const wanted = buildOptions(g.own, board, power)
.filter((o) => o.type === 'army' || o.at.includes('/') === false) .filter((o) => o.type === 'army' || o.at.includes('/') === false)
.slice(0, owed) .slice(0, owed)
board = applyAdjustments( const raised = wanted.map((o) => ({ type: 'build' as const, at: o.at, unit: o.type }))
g.own, board = applyAdjustments(g.own, board, power, raised).board
board, adjusted.push(...raised)
power,
wanted.map((o) => ({ type: 'build' as const, at: o.at, unit: o.type })),
).board
if (wanted.length > 0) log.push(`${power} builds ${wanted.length}.`) if (wanted.length > 0) log.push(`${power} builds ${wanted.length}.`)
} else { } else {
const going = civilDisorderDisbands(g.own, board, power, -owed) const going = civilDisorderDisbands(g.own, board, power, -owed)
board = applyAdjustments( const paidOff = going.map((u) => ({ type: 'disband' as const, at: u.at, unit: u.type }))
g.own, board = applyAdjustments(g.own, board, power, paidOff).board
board, adjusted.push(...paidOff)
power,
going.map((u) => ({ type: 'disband' as const, at: u.at, unit: u.type })),
).board
if (going.length > 0) log.push(`${power} gives up ${going.length}.`) if (going.length > 0) log.push(`${power} gives up ${going.length}.`)
} }
} }
@@ -347,6 +359,7 @@ export function resolveBuilds(
...g, ...g,
board, board,
log: [...log, `Spring ${g.year + 1}.`], log: [...log, `Spring ${g.year + 1}.`],
sounds: buildsSounded(adjusted),
year: g.year + 1, year: g.year + 1,
season: 'spring', season: 'spring',
phase: 'orders', phase: 'orders',
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { adjudicate } from './adjudicate'
import type { Power } from './map'
import { boardFrom, type Order, type Unit } from './orders'
import { buildsSounded, endingSounded, movesSounded, retreatsSounded } from './sound'
/**
* What a turn sounds like, which is a question with a right answer.
*
* The interesting cases are the quiet ones. A turn in which everybody walks
* into empty country should make no sound at all, and getting that wrong is
* the failure that matters -- guns on every turn are the same as no guns.
*/
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at })
const mv = (at: string, to: string, viaConvoy = false): Order => ({
type: 'move',
at,
to,
viaConvoy,
})
const sup = (at: string, from: string, to: string): Order => ({ type: 'support', at, from, to })
const hold = (at: string): Order => ({ type: 'hold', at })
const cvy = (at: string, from: string, to: string): Order => ({ type: 'convoy', at, from, to })
const sounds = (units: Unit[], orders: Order[]) => {
const board = boardFrom(units)
return movesSounded(board, orders, adjudicate(board, orders))
}
describe('the movement phase', () => {
it('is silent when nobody meets anybody', () => {
expect(sounds([A('austria', 'vie'), A('italy', 'rom')], [mv('vie', 'tyr'), mv('rom', 'ven')]))
.toEqual([])
})
it('fires the guns when two armies want the same province', () => {
expect(sounds([A('austria', 'vie'), A('italy', 'ven')], [mv('vie', 'tyr'), mv('ven', 'tyr')]))
.toEqual(['ground'])
})
it('fires them when an attack fails against somebody standing there', () => {
expect(sounds([A('germany', 'ruh'), A('france', 'bur')], [mv('ruh', 'bur'), hold('bur')]))
.toEqual(['ground'])
})
it('fires them when somebody is thrown out', () => {
const units = [A('germany', 'ruh'), A('germany', 'mun'), A('france', 'bur')]
const orders = [mv('ruh', 'bur'), sup('mun', 'ruh', 'bur'), hold('bur')]
expect(sounds(units, orders)).toEqual(['ground'])
})
it('calls it naval when the fight is at sea', () => {
const units = [F('england', 'lon'), F('france', 'bel')]
expect(sounds(units, [mv('lon', 'nth'), mv('bel', 'nth')])).toEqual(['naval'])
})
it('calls it naval when two fleets contest a coast', () => {
const units = [F('england', 'nth'), F('germany', 'hol')]
expect(sounds(units, [mv('nth', 'bel'), mv('hol', 'bel')])).toEqual(['naval'])
})
it('calls a landing ground, however it arrived', () => {
// A fleet against an army ashore is a landing, and a landing is ground.
const units = [F('england', 'nth'), A('france', 'bel')]
expect(sounds(units, [mv('nth', 'bel'), hold('bel')])).toEqual(['ground'])
})
it('sounds the escort when a convoy carries somebody', () => {
const units = [A('england', 'lon'), F('england', 'nth')]
expect(sounds(units, [mv('lon', 'nwy', true), cvy('nth', 'lon', 'nwy')])).toEqual(['escort'])
})
it('does not sound one for a fleet that carried nobody', () => {
const units = [A('england', 'lon'), F('england', 'nth')]
expect(sounds(units, [hold('lon'), cvy('nth', 'lon', 'nwy')])).toEqual([])
})
it('reports both when a convoy lands into a fight', () => {
const units = [A('england', 'lon'), F('england', 'nth'), F('england', 'nwg'), A('russia', 'nwy')]
const orders = [
mv('lon', 'nwy', true),
cvy('nth', 'lon', 'nwy'),
sup('nwg', 'lon', 'nwy'),
hold('nwy'),
]
expect(sounds(units, orders)).toEqual(['escort', 'ground'])
})
it('does not sound the escort for a landing that was thrown back', () => {
// The ships sailed, and the army is still in London. The game's account
// of the turn is that nothing crossed, so nothing crossed.
const units = [A('england', 'lon'), F('england', 'nth'), A('russia', 'nwy')]
const orders = [mv('lon', 'nwy', true), cvy('nth', 'lon', 'nwy'), hold('nwy')]
expect(sounds(units, orders)).toEqual(['ground'])
})
it('says each kind once, however many battles there were', () => {
const units = [
A('austria', 'vie'),
A('italy', 'ven'),
A('germany', 'ruh'),
A('france', 'bur'),
]
const orders = [mv('vie', 'tyr'), mv('ven', 'tyr'), mv('ruh', 'bur'), hold('bur')]
expect(sounds(units, orders)).toEqual(['ground'])
})
})
describe('the other phases', () => {
it('tells falling back from being finished', () => {
expect(retreatsSounded([{ type: 'retreat', at: 'bur', to: 'par' }])).toEqual(['retreat'])
expect(retreatsSounded([{ type: 'disband', at: 'bur' }])).toEqual(['disband'])
expect(
retreatsSounded([
{ type: 'retreat', at: 'bur', to: 'par' },
{ type: 'disband', at: 'mun' },
]),
).toEqual(['retreat', 'disband'])
})
it('tells a yard from a paying-off', () => {
expect(buildsSounded([{ type: 'build', at: 'vie', unit: 'army' }])).toEqual(['build'])
expect(buildsSounded([{ type: 'disband', at: 'vie' }])).toEqual(['disband'])
})
it('is silent over a winter where nothing was ordered', () => {
expect(buildsSounded([])).toEqual([])
expect(retreatsSounded([])).toEqual([])
})
})
describe('the endings', () => {
it('separates your solo from somebody elses', () => {
expect(endingSounded('austria', [], 'austria')).toBe('victory')
expect(endingSounded('russia', [], 'austria')).toBe('defeat')
})
it('puts your own elimination ahead of who eventually won', () => {
// You were out in 1904. Russia's eighteenth centre in 1911 is news you
// read, not an ending you had.
expect(endingSounded('russia', ['austria'], 'austria')).toBe('eliminated')
})
it('calls a game nobody won an armistice', () => {
expect(endingSounded(null, [], 'austria')).toBe('armistice')
})
})
+141
View File
@@ -0,0 +1,141 @@
import type { Outcome } from './adjudicate'
import { PROVINCES, base } from './map'
import type { Board, Order } from './orders'
import type { AdjustOrder, RetreatOrder } from './turn'
/**
* What a turn sounded like.
*
* The sounds are chosen from the orders and the outcome rather than fired
* from inside the interface, for the same reason the orders are validated in
* the rules rather than in the panel: this is a question with a right answer,
* and a right answer can be tested. Nothing here touches an AudioContext.
*
* Each kind plays at most once. A turn with four land battles in it is not
* four times as loud as a turn with one -- it is mud, and the player learns
* nothing from it. So the report says *what kinds of thing happened*, and the
* player looks at the board to find out where.
*/
export type Cue = 'escort' | 'naval' | 'ground' | 'retreat' | 'disband' | 'build'
/** Fixed, so a turn always plays its sounds in the same order. */
const ORDER: Cue[] = ['escort', 'naval', 'ground', 'retreat', 'disband', 'build']
const sorted = (cues: Set<Cue>): Cue[] => ORDER.filter((c) => cues.has(c))
/**
* The movement phase.
*
* A province was fought over when somebody was thrown out of it, when two
* units were sent to it, or when one unit was sent against another and did
* not get in. A unit walking into empty country is not a battle, and a turn
* where every power does that should be silent -- which happens, and is
* worth hearing, because it means nobody committed to anything.
*/
export function movesSounded(
board: Board,
orders: readonly Order[],
outcome: Outcome,
): Cue[] {
const cues = new Set<Cue>()
const arrivals = new Map<string, Order[]>()
for (const order of orders) {
if (order.type !== 'move') continue
const dest = base(order.to)
const list = arrivals.get(dest)
if (list) list.push(order)
else arrivals.set(dest, [order])
}
for (const [dest, movers] of arrivals) {
const held = board.get(dest)
const contested =
outcome.dislodged.has(dest) ||
movers.length > 1 ||
(held !== undefined && !outcome.success.get(base(movers[0]!.at)))
if (!contested) continue
/*
* Naval when it happened at sea, or when everybody involved was a fleet
* -- two fleets contesting a coast is a fight between ships wherever the
* pin is. A fleet against an army on a coast is a landing, and a landing
* is ground.
*/
const involved = [...movers.map((m) => board.get(base(m.at))), held].filter(
(u) => u !== undefined,
)
const atSea = PROVINCES[dest]?.terrain === 'sea'
cues.add(atSea || involved.every((u) => u.type === 'fleet') ? 'naval' : 'ground')
}
/*
* An escort is a convoy that actually carried somebody across.
*
* Three things have to be true, and the two easy ones are not enough. A
* fleet ordered to convoy an army that stayed at home did not escort
* anything -- and a held order *succeeds*, so asking whether the army's
* order worked is the wrong question. Ask whether it was ordered to make
* this crossing, and whether it arrived.
*/
const crossings = new Map<string, string>()
for (const order of orders) {
if (order.type === 'move') crossings.set(base(order.at), base(order.to))
}
for (const order of orders) {
if (order.type !== 'convoy') continue
if (!outcome.success.get(base(order.at))) continue
if (crossings.get(base(order.from)) !== base(order.to)) continue
if (!outcome.success.get(base(order.from))) continue
cues.add('escort')
}
return sorted(cues)
}
/** The retreat phase: somebody fell back, or somebody did not. */
export function retreatsSounded(chosen: readonly RetreatOrder[]): Cue[] {
const cues = new Set<Cue>()
for (const order of chosen) cues.add(order.type === 'retreat' ? 'retreat' : 'disband')
return sorted(cues)
}
/** The winter. */
export function buildsSounded(chosen: readonly AdjustOrder[]): Cue[] {
const cues = new Set<Cue>()
for (const order of chosen) cues.add(order.type === 'build' ? 'build' : 'disband')
return sorted(cues)
}
/**
* The four endings.
*
* They are four rather than two because losing has more than one shape. Being
* eliminated in 1904 and watching Russia take an eighteenth centre in 1911
* are both defeats, and they do not feel remotely alike -- one is your own
* ending and the other is somebody else's, which you merely attended.
*/
export type Ending = 'victory' | 'defeat' | 'eliminated' | 'armistice'
export function endingSounded(
winner: string | null,
out: readonly string[],
player: string,
): Ending {
if (winner === player) return 'victory'
if (out.includes(player)) return 'eliminated'
if (winner) return 'defeat'
return 'armistice'
}
/**
* Two reports, run together.
*
* A submitted turn can pass through the movement, the retreats and the winter
* before it stops anywhere the player has a say, and each of those has
* something to say about how it sounded. Merging keeps the fixed order rather
* than the order they were collected in, so a winter's guns still come before
* its shipyards.
*/
export const mergeCues = (a: readonly Cue[], b: readonly Cue[]): Cue[] =>
sorted(new Set([...a, ...b]))