From 0208a10da610ee550080a3f75f6b1742a98327bf Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 9 Sep 2026 09:01:41 -0700 Subject: [PATCH] Retreats and the winter, given back to the player Both were being taken for the player, the same way a computer power's are, which quietly removed half the consequence of the turn they had just played. A beaten unit that finds somewhere to stand is a nuisance for years and one that does not is gone; the winter is where a good spring turns into a bigger army, or does not. Offered as places rather than as a notation, for the same reason the orders are: the rules already know what is allowed, so the interface only ever offers what is. A beaten unit lists the provinces it may fall back to and nothing else, with Disband beside them. The winter lists the home centres that are yours and empty, and offers a fleet only where a fleet could sit -- so St Petersburg offers two coasts and Moscow offers none. The game steps past anything with no decision in it. A retreat phase where nothing of yours was thrown out, or a winter where your centres and units are level, is not a choice, it is a screen asking you to press Done. --- README.md | 19 +++- src/App.css | 27 +++++ src/App.tsx | 87 ++++++++++++++-- src/components/AdjustPanel.tsx | 179 +++++++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 src/components/AdjustPanel.tsx diff --git a/README.md b/README.md index 3fa7903..7b5ed08 100644 --- a/README.md +++ b/README.md @@ -228,8 +228,25 @@ Worth saying plainly: seven bots left alone draw. They take centres off each other now, which they could not do at all before, but none of them breaks away to a solo. Beating six of them is the game. +## The other two phases + +A beaten unit that finds somewhere to stand is a nuisance for years; one that +does not is gone. And the winter is where a good spring turns into a bigger +army, or does not. Both are decisions, and both were being taken *for* the +player while the rest of this was built, which quietly removed half the +consequence of the turn they had just played. + +They are offered as places rather than as a notation, for the same reason the +orders are: the rules already know what is allowed, so the interface only +ever offers what is. A beaten unit lists the provinces it may fall back to and +nothing else; the winter lists the home centres that are yours and empty, with +a fleet offered only where a fleet could sit. + +The game steps past anything with no decision in it. A retreat phase where +none of your units was thrown out, or a winter where your centres and units +are level, is not a choice -- it is a screen asking you to press Done. + ## Still to build -- the press in front of the player, rather than only between the bots - bots strong enough to solo against each other, not only to draw - the music, the battle sound, the four endings diff --git a/src/App.css b/src/App.css index 521ecb8..3e13e98 100644 --- a/src/App.css +++ b/src/App.css @@ -215,6 +215,33 @@ header select { .powers li.me { color: #ffd479; } +/* --- retreats and the winter ---------------------------------------- */ + +.adjust { display: flex; flex-direction: column; gap: 7px; } + +.adjust h2 { margin: 0; font-size: 0.95rem; color: #ffd479; } + +.beaten { display: flex; flex-direction: column; gap: 4px; } + +.choices { display: flex; flex-wrap: wrap; gap: 4px; } + +.choices button { + font: inherit; + font-size: 0.76rem; + font-weight: 700; + color: #f2e9d8; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 2px 8px; + cursor: pointer; +} + +.choices button.on { background: #2f5d3a; } +.choices button.no { background: #4a2b26; } +.choices button.no.on { background: #6d2f28; } +.choices button:disabled { opacity: 0.35; cursor: default; } + /* --- the press ------------------------------------------------------- */ .press h2 { margin: 0 0 4px; font-size: 0.95rem; color: #ffd479; } diff --git a/src/App.tsx b/src/App.tsx index 5451292..57b196e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Board, COLOURS, POWER_NAMES } from './components/Board' +import { BuildPanel, RetreatPanel } from './components/AdjustPanel' import { OrderPanel, type Step } from './components/OrderPanel' import { PressPanel } from './components/PressPanel' import { consider, type Overture } from './game/bot' @@ -17,7 +18,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 { centreCount } from './game/turn' +import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn' import './App.css' /** @@ -33,6 +34,8 @@ export default function App() { const [asked, setAsked] = useState([]) const [step, setStep] = useState({ kind: 'idle' }) const [orders, setOrders] = useState>(new Map()) + const [retreats, setRetreats] = useState>(new Map()) + const [builds, setBuilds] = useState([]) /* * The talking happens once per orders phase. Keeping a note of which turn @@ -141,19 +144,52 @@ export default function App() { return reply.why } + /** + * Carry the game forward past anything the player has no say in. + * + * A retreat phase with none of your units beaten, or a winter where your + * centres and units are level, is not a decision -- it is a screen asking + * you to press Done. So those are stepped through, and the game stops only + * where there is actually a choice to make. + */ + const advance = useCallback( + (from: Game): Game => { + let g = from + for (;;) { + if (g.phase === 'retreats') { + const mineBeaten = [...(g.outcome?.dislodged.values() ?? [])].some( + (d) => d.unit.power === power, + ) + if (mineBeaten) return g + g = resolveRetreats(g, power, []) + continue + } + if (g.phase === 'builds') { + if (adjustmentFor(g.own, g.board, power) !== 0) return g + g = resolveBuilds(g, power, []) + continue + } + return g + } + }, + [power], + ) + const submit = () => { - let next = resolveOrders(game, power, [...orders.values()]) - // The player's own retreats and builds are taken for them for now, the - // same way a computer power's are. Choosing them is the next piece. - while (next.phase === 'retreats' || next.phase === 'builds') { - next = next.phase === 'retreats' - ? resolveRetreats(next, power, []) - : resolveBuilds(next, power, []) - } setOrders(new Map()) setStep({ kind: 'idle' }) setAsked([]) - setGame(next) + setGame(advance(resolveOrders(game, power, [...orders.values()]))) + } + + const doneRetreating = () => { + setGame(advance(resolveRetreats(game, power, [...retreats.values()]))) + setRetreats(new Map()) + } + + const doneBuilding = () => { + setGame(advance(resolveBuilds(game, power, builds))) + setBuilds([]) } const mine = [...units.entries()].filter(([, u]) => u.power === power) @@ -185,8 +221,10 @@ export default function App() { ))} )} - {game.phase !== 'over' && + {game.phase === 'orders' && `. ${mine.length - orders.size} of ${mine.length} units still without orders.`} + {game.phase === 'retreats' && '. Somebody of yours was thrown out.'} + {game.phase === 'builds' && '. The winter.'}

@@ -212,6 +250,30 @@ export default function App() { ))} + {game.phase === 'retreats' && game.outcome && ( + setRetreats((prev) => new Map(prev).set(base(o.at), o))} + onDone={doneRetreating} + /> + )} + + {game.phase === 'builds' && ( + setBuilds((prev) => [...prev, o])} + onDrop={(at) => setBuilds((prev) => prev.filter((c) => base(c.at) !== base(at)))} + onDone={doneBuilding} + /> + )} + + {game.phase === 'orders' && ( + )} + {game.phase === 'orders' && ( + )}
    {game.log.slice(-6).map((line, i) => ( diff --git a/src/components/AdjustPanel.tsx b/src/components/AdjustPanel.tsx new file mode 100644 index 0000000..6f364e9 --- /dev/null +++ b/src/components/AdjustPanel.tsx @@ -0,0 +1,179 @@ +import { PROVINCES, base, type Power } from '../game/map' +import type { Unit } from '../game/orders' +import type { Outcome } from '../game/adjudicate' +import type { Board } from '../game/orders' +import { + adjustmentFor, + buildOptions, + retreatOptions, + type AdjustOrder, + type Ownership, + type RetreatOrder, +} from '../game/turn' + +/** + * The two phases that are not orders, and are decisions all the same. + * + * A beaten unit that finds somewhere to stand is a nuisance for years; one + * that does not is gone. And the winter is where a good spring turns into a + * bigger army, or does not. Taking either of these away from the player -- + * which is what happened while this was being built -- quietly removes half + * the consequence of the turn they just played. + * + * Both are offered as places rather than as a notation, for the same reason + * the orders are: the rules already know what is allowed, so the interface + * should only ever offer what is. + */ + +const name = (id: string) => PROVINCES[base(id)]!.name + +export function RetreatPanel({ + power, + board, + outcome, + chosen, + onChoose, + onDone, +}: { + power: Power + board: Board + outcome: Outcome + chosen: ReadonlyMap + onChoose: (order: RetreatOrder) => void + onDone: () => void +}) { + const beaten = [...outcome.dislodged.entries()].filter(([, d]) => d.unit.power === power) + + return ( +
    +

    Retreats

    + {beaten.length === 0 ? ( +

    Nothing of yours was thrown out.

    + ) : ( + beaten.map(([at, d]) => { + const where = retreatOptions(board, outcome, at) + const picked = chosen.get(at) + return ( +
    +

    + {d.unit.type === 'fleet' ? 'Fleet' : 'Army'} {name(at)} + {where.length === 0 && ' — nowhere to go'} +

    +
    + {where.map((to) => ( + + ))} + +
    +
    + ) + }) + )} + +
    + ) +} + +export function BuildPanel({ + power, + board, + own, + chosen, + onChoose, + onDrop, + onDone, +}: { + power: Power + board: Board + own: Ownership + chosen: readonly AdjustOrder[] + onChoose: (order: AdjustOrder) => void + onDrop: (at: string) => void + onDone: () => void +}) { + const owed = adjustmentFor(own, board, power) + const left = Math.abs(owed) - chosen.length + const mine = [...board.entries()].filter(([, u]) => u.power === power) + + return ( +
    +

    {owed >= 0 ? 'Builds' : 'Disbands'}

    +

    + {owed === 0 + ? 'Your centres and your units are level.' + : owed > 0 + ? `You may build ${owed}. ${left} left.` + : `You must give up ${-owed}. ${left} left.`} +

    + + {owed > 0 && ( +
    + {buildOptions(own, board, power) + .filter((o) => !chosen.some((c) => base(c.at) === base(o.at))) + .map((o) => ( + + ))} +
    + )} + + {owed < 0 && ( +
    + {mine + .filter(([at]) => !chosen.some((c) => base(c.at) === at)) + .map(([at, u]) => ( + + ))} +
    + )} + + {chosen.length > 0 && ( +
      + {chosen.map((c) => ( +
    • + + {c.type === 'build' ? 'Build' : 'Give up'} {name(c.at)} + + +
    • + ))} +
    + )} + + +
    + ) +} + +export type { Unit }