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
|
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
@@ -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
@@ -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) => (
|
||||||
|
|||||||
@@ -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