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
+18 -1
View File
@@ -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 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. 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 ## 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 - bots strong enough to solo against each other, not only to draw
- the music, the battle sound, the four endings - the music, the battle sound, the four endings
+27
View File
@@ -215,6 +215,33 @@ header select {
.powers li.me { color: #ffd479; } .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 ------------------------------------------------------- */ /* --- the press ------------------------------------------------------- */
.press h2 { margin: 0 0 4px; font-size: 0.95rem; color: #ffd479; } .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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Board, COLOURS, POWER_NAMES } from './components/Board' import { Board, COLOURS, POWER_NAMES } from './components/Board'
import { BuildPanel, RetreatPanel } from './components/AdjustPanel'
import { OrderPanel, type Step } from './components/OrderPanel' import { OrderPanel, type Step } from './components/OrderPanel'
import { PressPanel } from './components/PressPanel' import { PressPanel } from './components/PressPanel'
import { consider, type Overture } from './game/bot' 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 { 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 { centreCount } from './game/turn' import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn'
import './App.css' import './App.css'
/** /**
@@ -33,6 +34,8 @@ export default function App() {
const [asked, setAsked] = useState<Overture[]>([]) const [asked, setAsked] = useState<Overture[]>([])
const [step, setStep] = useState<Step>({ kind: 'idle' }) const [step, setStep] = useState<Step>({ kind: 'idle' })
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 [builds, setBuilds] = useState<AdjustOrder[]>([])
/* /*
* 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
@@ -141,19 +144,52 @@ export default function App() {
return reply.why 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 = () => { 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()) setOrders(new Map())
setStep({ kind: 'idle' }) setStep({ kind: 'idle' })
setAsked([]) 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) const mine = [...units.entries()].filter(([, u]) => u.power === power)
@@ -185,8 +221,10 @@ export default function App() {
))} ))}
</select> </select>
)} )}
{game.phase !== 'over' && {game.phase === 'orders' &&
`. ${mine.length - orders.size} of ${mine.length} units still without 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> </p>
</header> </header>
@@ -212,6 +250,30 @@ export default function App() {
))} ))}
</ul> </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 <PressPanel
power={power} power={power}
turn={turnOf(game)} turn={turnOf(game)}
@@ -222,7 +284,9 @@ export default function App() {
onAnswer={answer} onAnswer={answer}
onAsk={ask} onAsk={ask}
/> />
)}
{game.phase === 'orders' && (
<OrderPanel <OrderPanel
units={units} units={units}
orders={orders} orders={orders}
@@ -232,6 +296,7 @@ export default function App() {
onClear={clear} onClear={clear}
onSubmit={submit} onSubmit={submit}
/> />
)}
<ul className="log"> <ul className="log">
{game.log.slice(-6).map((line, i) => ( {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 }