From e299050ea4d5ec4a881a2cc1fc32e1b1e0125c90 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 13:14:58 -0700 Subject: [PATCH] A game you could not leave Two holes, and the same one twice. When the game ended, every panel in the side column was gated on the phase and simply went: the header announced the result and the page then sat there, with no way to start again and no way off it short of the browser's back button. While a game was running there was no way out either -- the only links off the app were on the landing page, which you had already left. So the end of a game now ends in a panel, where the orders used to be. It says what the ending was from the seat the player was sitting in, which is a different sentence to the header's: a draw the survivors agreed to and a draw you were eliminated long before are the same result and not the same afternoon. It is read from the same endingSounded() the closing music is chosen from, so the words and the noise cannot disagree. Under it, the two things left to do -- another game, or the rest of the games. Play again resets the refs as well as the state, and that is the part that would have bitten: `ended` is what stops the closing music playing twice, and `talked` is keyed on the turn already negotiated, so a stale note there would have had the second game opened in silence by six powers who never spoke. The ways out of a game still being played go in the header, because that is the one part of the screen always on it -- the side column scrolls, and on a narrow window it sits below a map most of a screen tall. Leaving is asked twice like resigning beside it, for the same reason and in the same place rather than in a browser dialog, and the armed wording says what is actually lost rather than asking whether you are sure. The two confirmations disarm each other, so half-pressing one and reaching for the other does not find it already waiting on its second click. --- src/App.css | 76 ++++++++++++++++++++++++- src/App.tsx | 157 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 218 insertions(+), 15 deletions(-) diff --git a/src/App.css b/src/App.css index 92bbd8d..92cf44c 100644 --- a/src/App.css +++ b/src/App.css @@ -17,8 +17,8 @@ padding: clamp(10px, 2vw, 22px); } -/* 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. */ +/* The title on the left, the ways out on the right, the state of play under + both -- so none of them takes a line of its own. */ header { grid-area: head; display: grid; @@ -27,6 +27,38 @@ header { column-gap: 12px; } +/* + * Leaving, and the sound. Wrapping rather than shrinking, because on a narrow + * window the alternative is three items squeezing the title off its own line. + */ +header .ways { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: baseline; + gap: 6px 10px; +} + +header .ways a { + font-size: 0.85rem; + font-weight: 700; + color: #7fc4e8; +} + +/* Red like the resign button, and for the same reason: this one cannot be + taken back either. */ +.leave { + font: inherit; + font-weight: 700; + font-size: 0.85rem; + color: #ffb3a7; + background: #2c3b57; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 3px 10px; + cursor: pointer; +} + h1 { margin: 0; font-size: clamp(1.4rem, 3.2vw, 2.2rem); @@ -520,3 +552,43 @@ header select { font-size: 0.76rem; color: #b99a4e; } + +/* --------------------------------------------------------- the ending */ + +/* + * The panel that ends the game. + * + * It takes the place the order panel had, which is where a player's eye + * already is when the last turn resolves, and it carries the only two things + * left to do: start another one, or leave. Bordered rather than merely + * spaced, because everything else in this column is a thing you are being + * asked to do and this one is a thing that has happened. + */ +.over { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 12px; + border: 2px solid #2b2318; + border-radius: 8px; + background: #22304a; +} + +.over h2 { margin: 0; font-size: 1rem; color: #ffd479; } +.over p { margin: 0; font-size: 0.84rem; line-height: 1.45; color: #cbd6e6; } + +.over .again { + font: inherit; + font-size: 0.9rem; + font-weight: 800; + color: #241a10; + background: #ffd479; + border: 2px solid #2b2318; + border-radius: 7px; + padding: 6px 16px; + cursor: pointer; +} + +.over .ways { font-size: 0.82rem; } +.over .ways a { color: #7fc4e8; } diff --git a/src/App.tsx b/src/App.tsx index 718ca0d..9bb61d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,7 @@ import { reachableFrom } from './game/layout' import { POWERS, PROVINCES, base, type Power } from './game/map' import { canStep, validate, type Order, type Unit } from './game/orders' import type { Proposal } from './game/press' -import { endingSounded, mergeCues, type Cue } from './game/sound' +import { endingSounded, mergeCues, type Cue, type Ending } from './game/sound' import { convoyDestinations, convoyTargets, @@ -56,6 +56,7 @@ export default function App() { const [started, setStarted] = useState(false) const [said, setSaid] = useState([]) const [sure, setSure] = useState(false) + const [leaving, setLeaving] = useState(false) // ------------------------------------------------------------ audio glue @@ -328,6 +329,7 @@ export default function App() { // one button here that cannot be taken back. if (!sure) { setSure(true) + setLeaving(false) if (synth.sfxOn) synth.reject() return } @@ -339,6 +341,7 @@ export default function App() { const askDraw = () => { setSure(false) + setLeaving(false) if (synth.sfxOn) synth.telegraph(false) const { game: next, verdicts } = askForDraw(game, power) setGame(next) @@ -347,6 +350,7 @@ export default function App() { const askConcede = () => { setSure(false) + setLeaving(false) const leader = POWERS.reduce((best, p) => centreCount(own, p) > centreCount(own, best) ? p : best, ) @@ -361,9 +365,61 @@ export default function App() { setBuilds([]) } + /** + * Back to the landing page, with nothing carried over. + * + * The refs matter as much as the state does. `ended` is what stops the + * closing music playing twice, and `talked` is the turn the powers have + * already negotiated -- and a fresh game starts at the same turn as the + * last one did, so a stale note there would have the second game played in + * silence by six powers who never spoke. `woken` is deliberately left + * alone: the audio context is open and does not need opening again. + */ + const restart = useCallback(() => { + ended.current = false + talked.current = '' + setGame(newGame()) + setAsked([]) + setStep({ kind: 'idle' }) + setOrders(new Map()) + setRetreats(new Map()) + setBuilds([]) + setSaid([]) + setSure(false) + setLeaving(false) + setStarted(false) + if (synth.sfxOn) synth.tap() + }, []) + + /** + * Out of a game that is still being played. + * + * Asked twice, like the resign button it sits across the screen from, and + * for the same reason: it throws away a game that cannot be got back, and + * there is no browser dialog on the way to catch a misclick. The armed + * wording says what is actually lost rather than asking whether you are + * sure, which tells nobody anything. + * + * The two confirmations disarm each other. Half-pressing this and then + * reaching for Resign should not find Resign already waiting on its second + * click. + */ + const leave = () => { + if (!leaving) { + setLeaving(true) + setSure(false) + if (synth.sfxOn) synth.reject() + return + } + restart() + } + const mine = [...units.entries()].filter(([, u]) => u.power === power) const season = game.season === 'spring' ? 'Spring' : 'Autumn' const quit = game.resigned.includes(power) || game.out.includes(power) + // The same reading of the ending the closing music is chosen from, so the + // panel and the noise it arrives with are never telling different stories. + const ending = endingSounded(game.winner, game.out, power) if (!started) { return ( @@ -387,18 +443,42 @@ export default function App() {

Great Powers

- + {/* + The ways out, kept in the header because that is the one part of + this screen that is always on it -- the side column scrolls, and on + a narrow window it sits below a map most of a screen tall. A game + you cannot leave without the browser's back button is a game that + has taken the page hostage. + + There is nothing here once the game is over: the panel that replaces + the orders then offers both of these and a new game besides, and one + screen should have one place to look. + */} +

{game.phase === 'over' ? game.winner @@ -527,6 +607,30 @@ export default function App() {

)} + {game.phase === 'over' && ( + /* + * The way out. + * + * Every other phase ends in a button, and this one used to end in + * nothing at all: the result was announced in the header and the + * page then sat there, with no way to start again and no way off + * it short of the browser's own back button. A game that has + * finished has to say so and then let go. + */ +
+

{ENDINGS[ending].title}

+

{ENDINGS[ending].said}

+ +

+ {/* Absolute for the same reason the landing's is: served from a + subdirectory in production and from the root in development. */} + The rest of the games +

+
+ )} + {game.phase === 'orders' && !quit && ( = { + victory: { + title: 'The board is yours.', + said: 'Eighteen centres. Nobody has ever got there alone, so somebody helped you to it, and they will have noticed by now.', + }, + defeat: { + title: 'Beaten.', + said: 'Somebody else reached eighteen. That only ever happens because the rest of the board let it.', + }, + eliminated: { + title: 'Off the board.', + said: 'Your last centre went and the game carried on without you. Your centres are somebody else’s now.', + }, + armistice: { + title: 'The armistice.', + said: 'Nobody reached eighteen. What is left is divided between the powers still standing, and you are one of them.', + }, +} + function coastOf(unit: Unit, province: string): string { const coasts = PROVINCES[province]?.coasts if (unit.type === 'army' || !coasts) return province