diff --git a/README.md b/README.md index 1ea574f..f3bde5c 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,25 @@ refused to make that offer would be playing a politer game than this one. It will not, however, ask the power it is attacking to *help*, or offer it peace while the orders say otherwise. +## Giving orders + +Clicks on the board, because that is where the question is. Select a unit, +pick a verb, and the provinces the next click will accept light up -- so +nobody writes a move the rules were never going to allow, and nobody has to +learn a notation to find that out. + +Everything offered comes from the rules rather than from the map, which is +the same split the map itself is built on. A fleet on the north coast of +Spain is drawn identically to one on the south coast and can go to entirely +different places; no shape can express that, and the highlight has to. + +Orders are drawn where they are given: an arrow to where a unit is going, a +dashed line to what a support is holding up, a ring round a unit that is +staying. The list beside the board says the same thing in the rules' own +words, because an arrow cannot tell you that the support you meant for Vienna +is being given to Budapest. Anything the rules will not take is struck +through in the list as it is written, rather than after the turn. + ## Still to build - the map, the orders, the music, the battle sound, the four endings diff --git a/src/App.css b/src/App.css index 3f20e22..70d5d7a 100644 --- a/src/App.css +++ b/src/App.css @@ -31,6 +31,16 @@ h1 { header p { margin: 2px 0 0; font-size: 0.9rem; } +header select { + font: inherit; + font-weight: 700; + color: #f2e9d8; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 1px 6px; +} + .dim { color: #a3b3c9; } .map-wrap { @@ -93,6 +103,96 @@ header p { margin: 2px 0 0; font-size: 0.9rem; } pointer-events: none; } +/* --- the orders ------------------------------------------------------ */ + +.orders .o-move { + stroke: #1c1208; + stroke-width: 6; + marker-end: url(#arrow); +} + +.orders .o-hold { + fill: none; + stroke: #1c1208; + stroke-width: 5; +} + +.orders .o-support line { + stroke: #1c1208; + stroke-width: 5; + stroke-dasharray: 16 11; +} + +.orders .o-convoy line { + stroke: #2f6f8f; + stroke-width: 5; + stroke-dasharray: 4 12; + stroke-linecap: round; +} + +.orders marker path { fill: #1c1208; } + +.orders-panel { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +} + +.orders-panel h2 { margin: 0; font-size: 0.98rem; } + +.verbs { display: flex; flex-wrap: wrap; gap: 4px; } + +.verbs button, +.submit { + font: inherit; + font-size: 0.82rem; + font-weight: 700; + color: #f2e9d8; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 7px; + padding: 3px 10px; + cursor: pointer; +} + +.verbs button.on { background: #b8341f; } + +.hint { margin: 0; font-size: 0.82rem; line-height: 1.4; } + +.written { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 3px; + overflow-y: auto; + min-height: 0; + font-size: 0.84rem; +} + +.written li { display: flex; align-items: center; gap: 6px; } + +/* An order the rules will not take, said so while it is being written. */ +.written li.bad span { color: #ff9a8b; text-decoration: line-through; } + +.written .drop { + margin-left: auto; + font: inherit; + color: #a3b3c9; + background: none; + border: none; + cursor: pointer; + padding: 0 3px; +} + +.written .drop:hover { color: #ff9a8b; } + +.submit { margin-top: auto; align-self: flex-start; background: #2f5d3a; } + +.powers li.me { color: #ffd479; } + /* --- the side ------------------------------------------------------- */ .side { diff --git a/src/App.tsx b/src/App.tsx index 6029bb9..052dab1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,72 +1,206 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Board, COLOURS, POWER_NAMES } from './components/Board' -import { OPENING, POWERS, PROVINCES } from './game/map' -import { boardFrom, type Unit } from './game/orders' +import { OrderPanel, type Step } from './components/OrderPanel' +import { reachableFrom } from './game/layout' +import { OPENING, POWERS, PROVINCES, base, type Power } from './game/map' +import { boardFrom, canStep, validate, type Order, type Unit } from './game/orders' import { centreCount, openingOwnership } from './game/turn' import './App.css' /** - * The opening position, on the board, so the map can be looked at. + * Spring 1901, with orders. * - * This is scaffolding: order entry, the press and the turn loop are not - * wired to it yet. What it is for is seeing whether seventy-five provinces - * and twenty-two units are legible at a glance, which is a question no test - * can answer. + * The whole interaction is clicks on the board, because that is where the + * question is. A unit is selected, a verb is chosen, and the provinces the + * next click will accept light up -- so a player never types a move that the + * rules were never going to allow, and never has to learn a notation to find + * that out. + * + * Everything is offered from the rules rather than from the map. A fleet on + * the north coast of Spain is drawn identically to one on the south coast and + * they can go to entirely different places, which no shape can express. */ export default function App() { - const [picked, setPicked] = useState(null) + const [power, setPower] = useState('austria') + const [step, setStep] = useState({ kind: 'idle' }) + const [orders, setOrders] = useState>(new Map()) const units = useMemo(() => { const all: Unit[] = [] - for (const power of POWERS) { - for (const at of OPENING[power].armies) all.push({ power, type: 'army', at }) - for (const at of OPENING[power].fleets) all.push({ power, type: 'fleet', at }) + for (const p of POWERS) { + for (const at of OPENING[p].armies) all.push({ power: p, type: 'army', at }) + for (const at of OPENING[p].fleets) all.push({ power: p, type: 'fleet', at }) } return boardFrom(all) }, []) const own = useMemo(openingOwnership, []) + /** Which provinces the next click will do something with. */ + const offering = useMemo(() => { + if (step.kind === 'idle') return new Set() + const unit = units.get(step.at) + if (!unit) return new Set() + + if (step.kind === 'move') return new Set(reachableFrom(unit).map(base)) + + if (step.kind === 'support') { + if (step.from === undefined) { + // Anybody I could reach: I can prop them up where they stand, or + // help them into somewhere I could have gone myself. + return new Set( + [...units.keys()].filter((p) => p !== step.at && canReach(unit, p)), + ) + } + const helped = units.get(step.from) + if (!helped) return new Set() + // Only where both of us could go: you cannot support a move you could + // not have made yourself. + return new Set( + reachableFrom(helped) + .map(base) + .filter((p) => canReach(unit, p) || p === step.from), + ) + } + + if (step.from === undefined) { + return new Set( + [...units.entries()] + .filter(([, u]) => u.type === 'army' && PROVINCES[base(u.at)]!.terrain === 'coast') + .map(([p]) => p), + ) + } + return new Set( + Object.keys(PROVINCES).filter((p) => PROVINCES[p]!.terrain === 'coast' && p !== step.from), + ) + }, [step, units]) + + const write = useCallback((order: Order) => { + setOrders((prev) => new Map(prev).set(base(order.at), order)) + setStep({ kind: 'idle' }) + }, []) + + const click = (province: string) => { + const here = units.get(province) + + // Starting again: clicking one of my own units always selects it. + if (step.kind === 'idle' || !offering.has(province)) { + if (here?.power === power) setStep({ kind: 'move', at: province }) + else setStep({ kind: 'idle' }) + return + } + + if (step.kind === 'move') { + write({ type: 'move', at: step.at, to: coastOf(units.get(step.at)!, province), power }) + return + } + + if (step.kind === 'support') { + if (step.from === undefined) { + setStep({ ...step, from: province }) + return + } + write({ type: 'support', at: step.at, from: step.from, to: province, power }) + return + } + + if (step.from === undefined) { + setStep({ ...step, from: province }) + return + } + write({ type: 'convoy', at: step.at, from: step.from, to: province, power }) + } + + const clear = (at: string) => { + setOrders((prev) => { + const next = new Map(prev) + next.delete(at) + return next + }) + setStep({ kind: 'idle' }) + } + + // What the rules make of the orders so far, live. A player should find out + // that a support is impossible while writing it, not after the turn. + const illegal = useMemo( + () => validate(units, [...orders.values()]).illegal, + [units, orders], + ) + + const mine = [...units.entries()].filter(([, u]) => u.power === power) + return (

Great Powers

-

Spring 1901 — the board before anybody has said anything.

+

+ Spring 1901 — playing{' '} + + . {mine.length - orders.size} of {mine.length} units still without orders. +

- +
) } + +/** Can this unit reach that province, by any coast of it? */ +function canReach(unit: Unit, province: string): boolean { + if (canStep(unit, province)) return true + const coasts = PROVINCES[province]?.coasts + return coasts !== undefined && coasts.some((c) => canStep(unit, `${province}/${c}`)) +} + +/** + * Which coast a fleet means. Where only one is reachable the order is + * unambiguous even though it did not say, which is the rule the adjudicator + * applies too -- so the interface should not make a fuss about it either. + */ +function coastOf(unit: Unit, province: string): string { + const coasts = PROVINCES[province]?.coasts + if (unit.type === 'army' || !coasts) return province + const open = coasts.map((c) => `${province}/${c}`).filter((k) => canStep(unit, k)) + return open.length === 1 ? open[0]! : province +} diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 7382d63..d73d0a7 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -1,6 +1,6 @@ import { PROVINCES, POWERS, base, type Power } from '../game/map' import { CENTRES, SHAPES, SHIFT, VIEW_BOX, reachableFrom } from '../game/layout' -import type { Board as Units } from '../game/orders' +import type { Board as Units, Order } from '../game/orders' import type { Ownership } from '../game/turn' /** @@ -33,17 +33,24 @@ const INK = '#2b2318' export function Board({ units, own, + orders, selected, + offering, onPick, }: { units: Units own: Ownership + /** Orders written so far, drawn on the board as they are given. */ + orders?: ReadonlyMap selected?: string | null + /** Provinces the current step will accept a click on. */ + offering?: ReadonlySet onPick?: (province: string) => void }) { const ids = Object.keys(PROVINCES) const standing = selected ? units.get(selected) : undefined - const reachable = new Set(standing ? reachableFrom(standing) : []) + const reachable = offering ?? new Set(standing ? reachableFrom(standing) : []) + const at = (id: string) => CENTRES[id] ?? CENTRES[base(id)]! const shade = (id: string) => { const p = PROVINCES[id]! @@ -94,6 +101,45 @@ export function Board({ ))} + {/* + The orders, drawn where they are being given. A move is an arrow to + where it is going; a support is a dashed line to what it is holding + up; a hold is a ring round the unit. Seeing them on the board is the + difference between checking your orders and re-reading a list. + */} + + + + + + + {[...(orders?.values() ?? [])].map((o) => { + const from = at(o.at) + if (o.type === 'hold') { + return + } + if (o.type === 'move') { + const to = at(o.to) + return ( + + ) + } + // A support props something up somewhere else: draw it to the + // province being held rather than to the unit doing the holding. + const target = at(o.type === 'support' ? o.to : o.to) + const via = at(o.from) + return ( + + + {base(o.from) !== base(o.to) && ( + + )} + + ) + })} + + {[...units.entries()].map(([at, unit]) => { const p = CENTRES[unit.at] ?? CENTRES[base(unit.at)] ?? CENTRES[at]! return ( diff --git a/src/components/OrderPanel.tsx b/src/components/OrderPanel.tsx new file mode 100644 index 0000000..25f7096 --- /dev/null +++ b/src/components/OrderPanel.tsx @@ -0,0 +1,118 @@ +import { PROVINCES, base } from '../game/map' +import type { Order, Unit } from '../game/orders' + +/** + * The orders as written, and the one control that matters: taking one back. + * + * The board shows what the orders *do*; this shows what they *say*, in the + * same words the rules use. Both are needed. An arrow on a map is quicker to + * read and cannot tell you that the support you meant to give Vienna is + * actually being given to Budapest. + */ + +export type Step = + | { kind: 'idle' } + | { kind: 'move'; at: string } + | { kind: 'support'; at: string; from?: string } + | { kind: 'convoy'; at: string; from?: string } + +const name = (id: string) => PROVINCES[base(id)]!.name + +export function say(order: Order, unit?: Unit): string { + const who = `${unit?.type === 'fleet' ? 'F' : 'A'} ${name(order.at)}` + switch (order.type) { + case 'hold': + return `${who} holds` + case 'move': + return `${who} → ${name(order.to)}` + case 'support': + return base(order.from) === base(order.to) + ? `${who} supports ${name(order.from)}` + : `${who} supports ${name(order.from)} → ${name(order.to)}` + case 'convoy': + return `${who} convoys ${name(order.from)} → ${name(order.to)}` + } +} + +export function OrderPanel({ + units, + orders, + step, + illegal, + onAsk, + onClear, + onSubmit, +}: { + units: ReadonlyMap + orders: ReadonlyMap + step: Step + illegal: ReadonlySet + onAsk: (kind: Step['kind']) => void + onClear: (at: string) => void + onSubmit: () => void +}) { + const selected = step.kind === 'idle' ? null : step.at + const unit = selected ? units.get(selected) : undefined + + return ( +
+ {unit && selected ? ( + <> +

+ {unit.type === 'fleet' ? 'Fleet' : 'Army'} {name(selected)} +

+
+ + + {unit.type === 'fleet' && PROVINCES[selected]!.terrain === 'sea' && ( + + )} + +
+

{hint(step)}

+ + ) : ( +

Click one of your units.

+ )} + +
    + {[...orders.entries()].map(([at, order]) => ( +
  • + {say(order, units.get(at))} + +
  • + ))} + {orders.size === 0 &&
  • Nothing ordered. Everybody holds.
  • } +
+ + +
+ ) +} + +function hint(step: Step): string { + switch (step.kind) { + case 'idle': + return '' + case 'move': + return 'Click where it should go.' + case 'support': + return step.from === undefined + ? 'Click the unit to support — or its own province, to hold it there.' + : 'Click where that unit is going.' + case 'convoy': + return step.from === undefined + ? 'Click the army to carry.' + : 'Click where it is going.' + } +}