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.
This commit is contained in:
2026-09-09 09:01:41 -07:00
parent 69adcc8f89
commit 0208a10da6
4 changed files with 300 additions and 12 deletions
+27
View File
@@ -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; }
+76 -11
View File
@@ -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<Overture[]>([])
const [step, setStep] = useState<Step>({ kind: 'idle' })
const [orders, setOrders] = useState<Map<string, Order>>(new Map())
const [retreats, setRetreats] = useState<Map<string, RetreatOrder>>(new Map())
const [builds, setBuilds] = useState<AdjustOrder[]>([])
/*
* 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() {
))}
</select>
)}
{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.'}
</p>
</header>
@@ -212,6 +250,30 @@ export default function App() {
))}
</ul>
{game.phase === 'retreats' && game.outcome && (
<RetreatPanel
power={power}
board={game.board}
outcome={game.outcome}
chosen={retreats}
onChoose={(o) => setRetreats((prev) => new Map(prev).set(base(o.at), o))}
onDone={doneRetreating}
/>
)}
{game.phase === 'builds' && (
<BuildPanel
power={power}
board={game.board}
own={game.own}
chosen={builds}
onChoose={(o) => setBuilds((prev) => [...prev, o])}
onDrop={(at) => setBuilds((prev) => prev.filter((c) => base(c.at) !== base(at)))}
onDone={doneBuilding}
/>
)}
{game.phase === 'orders' && (
<PressPanel
power={power}
turn={turnOf(game)}
@@ -222,7 +284,9 @@ export default function App() {
onAnswer={answer}
onAsk={ask}
/>
)}
{game.phase === 'orders' && (
<OrderPanel
units={units}
orders={orders}
@@ -232,6 +296,7 @@ export default function App() {
onClear={clear}
onSubmit={submit}
/>
)}
<ul className="log">
{game.log.slice(-6).map((line, i) => (
+179
View File
@@ -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<string, RetreatOrder>
onChoose: (order: RetreatOrder) => void
onDone: () => void
}) {
const beaten = [...outcome.dislodged.entries()].filter(([, d]) => d.unit.power === power)
return (
<div className="adjust">
<h2>Retreats</h2>
{beaten.length === 0 ? (
<p className="small dim">Nothing of yours was thrown out.</p>
) : (
beaten.map(([at, d]) => {
const where = retreatOptions(board, outcome, at)
const picked = chosen.get(at)
return (
<div className="beaten" key={at}>
<p className="who">
{d.unit.type === 'fleet' ? 'Fleet' : 'Army'} {name(at)}
{where.length === 0 && ' — nowhere to go'}
</p>
<div className="choices">
{where.map((to) => (
<button
key={to}
className={picked?.type === 'retreat' && picked.to === to ? 'on' : ''}
onClick={() => onChoose({ type: 'retreat', at, to })}
>
{name(to)}
{to.includes('/') ? ` (${to.split('/')[1]})` : ''}
</button>
))}
<button
className={`no ${picked?.type === 'disband' ? 'on' : ''}`}
onClick={() => onChoose({ type: 'disband', at })}
>
Disband
</button>
</div>
</div>
)
})
)}
<button className="submit" onClick={onDone}>
Done
</button>
</div>
)
}
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 (
<div className="adjust">
<h2>{owed >= 0 ? 'Builds' : 'Disbands'}</h2>
<p className="small dim">
{owed === 0
? 'Your centres and your units are level.'
: owed > 0
? `You may build ${owed}. ${left} left.`
: `You must give up ${-owed}. ${left} left.`}
</p>
{owed > 0 && (
<div className="choices">
{buildOptions(own, board, power)
.filter((o) => !chosen.some((c) => base(c.at) === base(o.at)))
.map((o) => (
<button
key={`${o.at}-${o.type}`}
disabled={left <= 0}
onClick={() => onChoose({ type: 'build', at: o.at, unit: o.type })}
>
{o.type === 'fleet' ? 'Fleet' : 'Army'} {name(o.at)}
{o.at.includes('/') ? ` (${o.at.split('/')[1]})` : ''}
</button>
))}
</div>
)}
{owed < 0 && (
<div className="choices">
{mine
.filter(([at]) => !chosen.some((c) => base(c.at) === at))
.map(([at, u]) => (
<button
key={at}
className="no"
disabled={left <= 0}
onClick={() => onChoose({ type: 'disband', at: u.at, unit: u.type })}
>
{u.type === 'fleet' ? 'Fleet' : 'Army'} {name(at)}
</button>
))}
</div>
)}
{chosen.length > 0 && (
<ul className="written">
{chosen.map((c) => (
<li key={c.at}>
<span>
{c.type === 'build' ? 'Build' : 'Give up'} {name(c.at)}
</span>
<button className="drop" onClick={() => onDrop(c.at)} title="Take it back">
×
</button>
</li>
))}
</ul>
)}
<button className="submit" onClick={onDone}>
Done
</button>
</div>
)
}
export type { Unit }