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:
@@ -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
|
||||
|
||||
+27
@@ -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
@@ -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) => (
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user