The press, in front of the player

The board is the game being played now rather than a picture of the opening:
the loop runs, powers approach you before every set of orders, and what you
agree is judged when the orders come in.

Approaches arrive as sentences with Agree and Refuse under them, because a
proposal is a thing somebody said and should not look like a row in a form.
Asking is done from an order already written -- I am going there, help me --
which is the only shape this request has ever taken, and the answer comes
back immediately in the power's own words.

Those words are now written for a person. A power that turns you down saying
'austria is worth more to me than boh' is telling the truth in a voice
nobody uses; it says Austria, and Bohemia, and puts a full stop on the end.
The turn log too.

Nothing in the panel enforces anything. Agreeing writes no order and stops no
order being written, and the gap between what was agreed and what was ordered
is the whole game -- which is why the panel also shows, per power, how many
promises you have kept and broken and how far each of them now believes you.

Retreats and builds are still taken for the player automatically, the same
way a computer power's are. Choosing them is the next piece.
This commit is contained in:
2026-09-09 08:46:24 -07:00
parent e0852c50aa
commit 69adcc8f89
7 changed files with 404 additions and 96 deletions
+84 -1
View File
@@ -215,6 +215,87 @@ header select {
.powers li.me { color: #ffd479; }
/* --- the press ------------------------------------------------------- */
.press h2 { margin: 0 0 4px; font-size: 0.95rem; color: #ffd479; }
.press h3 { margin: 8px 0 3px; font-size: 0.8rem; letter-spacing: 0.04em; color: #a3b3c9; }
.small { font-size: 0.78rem; }
.asks { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px; }
.asks li {
border: 2px solid #2b2318;
border-radius: 8px;
background: rgba(34, 48, 74, 0.55);
padding: 6px 8px;
}
.who { margin: 0; font-size: 0.74rem; font-weight: 800; letter-spacing: 0.06em; color: #a3b3c9; }
/* Their words, set as words. A proposal is a thing somebody said, and it
should not look like a row in a form. */
.quote { margin: 2px 0 6px; font-size: 0.84rem; line-height: 1.35; font-style: italic; }
.quote.reply { color: #ffd479; margin-top: 6px; }
.answer { display: flex; gap: 5px; }
.answer button,
.ask-row button {
font: inherit;
font-size: 0.76rem;
font-weight: 700;
color: #f2e9d8;
background: #2f5d3a;
border: 2px solid #2b2318;
border-radius: 6px;
padding: 2px 9px;
cursor: pointer;
}
.answer button.no { background: #6d2f28; }
.asking { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
.ask-row { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; }
.ask-row .whoms { margin-left: auto; display: flex; gap: 2px; }
.ask-row button { background: #22304a; padding: 1px 5px; font-size: 0.7rem; }
.deals { margin: 0; padding: 0; list-style: none; font-size: 0.8rem; }
.deals li { padding: 1px 0; }
.opinions { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 3px; }
.opinions li { display: grid; grid-template-columns: 5.5em 1fr 44px; align-items: center; gap: 6px; font-size: 0.78rem; }
/* How far each power believes you, which is the only number in this game
that a player earns rather than takes. */
.opinions .bar {
height: 7px;
border: 1.5px solid #2b2318;
border-radius: 999px;
background: #16202f;
overflow: hidden;
}
.opinions .bar i { display: block; height: 100%; background: #ffd479; }
.log {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.76rem;
line-height: 1.4;
color: #a3b3c9;
border-top: 1px solid #2b3a52;
padding-top: 6px;
}
.powers li.gone { opacity: 0.4; text-decoration: line-through; }
/* --- the side ------------------------------------------------------- */
.side {
@@ -222,7 +303,9 @@ header select {
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
overflow-y: auto;
padding-right: 4px;
}
.powers { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 5px; }
+128 -77
View File
@@ -1,68 +1,76 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Board, COLOURS, POWER_NAMES } from './components/Board'
import { OrderPanel, type Step } from './components/OrderPanel'
import { PressPanel } from './components/PressPanel'
import { consider, type Overture } from './game/bot'
import {
LAST_YEAR,
negotiate,
newGame,
resolveBuilds,
resolveOrders,
resolveRetreats,
turnOf,
type Game,
} from './game/game'
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 { 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 './App.css'
/**
* Spring 1901, with orders.
* The game, being played.
*
* 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.
* Everything is clicks on the board or answers in the panel, because those
* are the only two things this game asks of anybody: where are your units
* going, and do you mean what you told Russia.
*/
export default function App() {
const [power, setPower] = useState<Power>('austria')
const [game, setGame] = useState<Game>(newGame)
const [asked, setAsked] = useState<Overture[]>([])
const [step, setStep] = useState<Step>({ kind: 'idle' })
const [orders, setOrders] = useState<Map<string, Order>>(new Map())
const units = useMemo(() => {
const all: Unit[] = []
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)
}, [])
/*
* The talking happens once per orders phase. Keeping a note of which turn
* has been negotiated matters more than it looks: agreeing a deal changes
* the game, and a re-run on every change would have the powers proposing
* to each other in a loop for ever.
*/
const talked = useRef<string>('')
useEffect(() => {
const key = `${power}-${game.year}-${game.season}-${game.phase}`
if (game.phase !== 'orders' || talked.current === key) return
talked.current = key
const round = negotiate(game, power)
setGame(round.game)
setAsked(round.asked)
}, [game, power])
const own = useMemo(openingOwnership, [])
const own = game.own
const units = game.board
const pos = useMemo(() => ({ board: units, own }), [units, own])
/** Which provinces the next click will do something with. */
const offering = useMemo(() => {
if (step.kind === 'idle') return new Set<string>()
const unit = units.get(step.at)
if (!unit) return new Set<string>()
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)),
)
return new Set([...units.keys()].filter((p) => p !== step.at && canReach(unit, p)))
}
const helped = units.get(step.from)
if (!helped) return new Set<string>()
// 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()]
@@ -82,32 +90,20 @@ export default function App() {
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
if (step.from === undefined) return setStep({ ...step, from: province })
return write({ type: 'support', at: step.at, from: step.from, to: province, power })
}
if (step.from === undefined) return setStep({ ...step, from: province })
write({ type: 'convoy', at: step.at, from: step.from, to: province, power })
}
@@ -120,31 +116,77 @@ export default function App() {
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 illegal = useMemo(() => validate(units, [...orders.values()]).illegal, [units, orders])
/** Yes or no to somebody's approach. Neither answer binds anybody. */
const answer = (overture: Overture, yes: boolean) => {
setAsked((prev) => prev.filter((o) => o.proposal.id !== overture.proposal.id))
if (yes) setGame((g) => ({ ...g, agreements: [...g.agreements, overture.proposal] }))
}
/** Ask a power to support a move already written, and hear back at once. */
const ask = (to: Power, order: Extract<Order, { type: 'move' }>): string => {
const proposal: Proposal = {
id: `${power}-${to}-${turnOf(game)}-${base(order.to)}`,
from: power,
to,
turn: turnOf(game),
deal: { kind: 'support', mover: power, helper: to, from: order.at, to: order.to },
}
const theirs = { power: to, ledger: game.ledger, agreements: game.agreements }
const reply = consider(pos, theirs, proposal, turnOf(game))
if (reply.reply === 'accept') {
setGame((g) => ({ ...g, agreements: [...g.agreements, proposal] }))
}
return reply.why
}
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)
}
const mine = [...units.entries()].filter(([, u]) => u.power === power)
const season = game.season === 'spring' ? 'Spring' : 'Autumn'
return (
<div className="app">
<header>
<h1>Great Powers</h1>
<p className="dim">
Spring 1901 &mdash; playing{' '}
<select value={power} onChange={(e) => {
setPower(e.target.value as Power)
setOrders(new Map())
setStep({ kind: 'idle' })
}}>
{POWERS.map((p) => (
<option key={p} value={p}>{POWER_NAMES[p]}</option>
))}
</select>
. {mine.length - orders.size} of {mine.length} units still without orders.
{game.phase === 'over'
? game.winner
? `${POWER_NAMES[game.winner]} has eighteen centres. That is the game.`
: `Called at the end of ${game.year}. A draw between the survivors.`
: `${season} ${game.year} of ${LAST_YEAR} — playing `}
{game.phase !== 'over' && (
<select
value={power}
onChange={(e) => {
setPower(e.target.value as Power)
setOrders(new Map())
setStep({ kind: 'idle' })
}}
>
{POWERS.map((p) => (
<option key={p} value={p}>
{POWER_NAMES[p]}
</option>
))}
</select>
)}
{game.phase !== 'over' &&
`. ${mine.length - orders.size} of ${mine.length} units still without orders.`}
</p>
</header>
@@ -162,7 +204,7 @@ export default function App() {
<aside className="side">
<ul className="powers">
{POWERS.map((p) => (
<li key={p} className={p === power ? 'me' : ''}>
<li key={p} className={`${p === power ? 'me' : ''} ${game.out.includes(p) ? 'gone' : ''}`}>
<span className="swatch" style={{ background: COLOURS[p] }} />
{POWER_NAMES[p]}
<span className="count">{centreCount(own, p)}</span>
@@ -170,34 +212,43 @@ export default function App() {
))}
</ul>
<PressPanel
power={power}
turn={turnOf(game)}
ledger={game.ledger}
asked={asked}
agreements={game.agreements}
orders={orders}
onAnswer={answer}
onAsk={ask}
/>
<OrderPanel
units={units}
orders={orders}
step={step}
illegal={illegal}
onAsk={(kind) =>
setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))
}
onAsk={(kind) => setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))}
onClear={clear}
onSubmit={() => undefined}
onSubmit={submit}
/>
<ul className="log">
{game.log.slice(-6).map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</aside>
</div>
)
}
/** 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
+149
View File
@@ -0,0 +1,149 @@
import { useState } from 'react'
import { POWER_NAMES } from './Board'
import type { Overture } from '../game/bot'
import { PROVINCES, POWERS, base, type Power } from '../game/map'
import type { Order } from '../game/orders'
import { look, trust, type Agreement, type Ledger } from '../game/press'
/**
* What the powers are saying to you, and what you say back.
*
* The whole point of this game is here rather than on the board. A power
* asks for something in a sentence, you answer yes or no, and at the end of
* the turn the orders say whether either of you meant it. Nothing in this
* panel enforces anything -- accepting a deal writes no order and stops no
* order being written. That gap is the game.
*/
export function PressPanel({
power,
turn,
ledger,
asked,
agreements,
orders,
onAnswer,
onAsk,
}: {
power: Power
turn: number
ledger: Ledger
/** Approaches made to the player this turn. */
asked: readonly Overture[]
agreements: readonly Agreement[]
/** The player's own orders, so help can be asked for a move already written. */
orders: ReadonlyMap<string, Order>
onAnswer: (overture: Overture, yes: boolean) => void
onAsk: (to: Power, order: Extract<Order, { type: 'move' }>) => string
}) {
const [said, setSaid] = useState<{ to: Power; reply: string } | null>(null)
const mine = agreements.filter((a) => a.from === power || a.to === power)
const moves = [...orders.values()].filter(
(o): o is Extract<Order, { type: 'move' }> => o.type === 'move',
)
return (
<div className="press">
<h2>The press</h2>
{asked.length === 0 ? (
<p className="dim small">Nobody has approached you this turn.</p>
) : (
<ul className="asks">
{asked.map((o) => (
<li key={o.proposal.id}>
<p className="who">{POWER_NAMES[o.proposal.from]} says</p>
<p className="quote">&ldquo;{o.says}&rdquo;</p>
<div className="answer">
<button onClick={() => onAnswer(o, true)}>Agree</button>
<button className="no" onClick={() => onAnswer(o, false)}>
Refuse
</button>
</div>
</li>
))}
</ul>
)}
{/*
Asking is done from an order already written, because that is the
only shape this request has ever taken: I am going there, help me.
*/}
{moves.length > 0 && (
<div className="asking">
<p className="small dim">Ask for help with:</p>
{moves.map((o) => (
<div className="ask-row" key={o.at}>
<span>
{base(o.at)} &rarr; {base(o.to)}
</span>
<span className="whoms">
{POWERS.filter((p) => p !== power).map((p) => (
<button
key={p}
title={`Ask ${POWER_NAMES[p]}`}
onClick={() => setSaid({ to: p, reply: onAsk(p, o) })}
>
{POWER_NAMES[p].slice(0, 2)}
</button>
))}
</span>
</div>
))}
{said && (
<p className="quote reply">
{POWER_NAMES[said.to]}: &ldquo;{said.reply}&rdquo;
</p>
)}
</div>
)}
{mine.length > 0 && (
<>
<h3>Agreed this turn</h3>
<ul className="deals">
{mine.map((a) => (
<li key={a.id}>{describe(a, power)}</li>
))}
</ul>
</>
)}
<h3>What they make of you</h3>
<ul className="opinions">
{POWERS.filter((p) => p !== power).map((p) => {
const r = look(ledger, p, power)
return (
<li key={p}>
<span>{POWER_NAMES[p]}</span>
<span className="dim small">
{r.kept + r.broken === 0
? 'no dealings'
: `${r.kept} kept, ${r.broken} broken`}
</span>
<span className="bar" aria-hidden>
<i style={{ width: `${Math.round(trust(ledger, p, power, turn) * 100)}%` }} />
</span>
</li>
)
})}
</ul>
</div>
)
}
function describe(a: Agreement, me: Power): string {
const them = a.from === me ? a.to : a.from
const who = POWER_NAMES[them]
switch (a.deal.kind) {
case 'peace':
return `Peace with ${who}.`
case 'dmz':
return `${PROVINCES[base(a.deal.province)]!.name} left empty, with ${who}.`
case 'support':
return a.deal.helper === me
? `You support ${who}: ${base(a.deal.from)}${base(a.deal.to)}.`
: `${who} supports you: ${base(a.deal.from)}${base(a.deal.to)}.`
}
}
+2 -1
View File
@@ -260,7 +260,8 @@ describe('a bot doing the asking', () => {
expect(ask).toBeDefined()
expect(ask!.proposal.to).toBe('england')
expect(ask!.says).toContain('bel')
// The sentence is written for a person to read, so it says Belgium.
expect(ask!.says).toContain('Belgium')
})
it('never asks the power it is attacking to help it', () => {
+19 -10
View File
@@ -321,6 +321,15 @@ export interface Answer {
why: string
}
/*
* These sentences are read by a person, so they are written for one: proper
* names, and the province called what it is called on the map. A power that
* turns you down saying "austria is worth more to me than boh" is telling
* you the truth in a voice nobody uses.
*/
const named = (power: Power): string => power[0]!.toUpperCase() + power.slice(1)
const place = (id: string): string => PROVINCES[base(id)]!.name
/**
* Whether to accept an offer.
*
@@ -338,30 +347,30 @@ export function consider(pos: Position, mind: Mind, proposal: Proposal, turn: nu
if (deal.mover === mind.power) {
const worth = desire(pos, mind.power, deal.to) * believe
return worth > FRIEND
? { reply: 'accept', why: `${base(deal.to)} is worth having and ${them} may mean it` }
: { reply: 'refuse', why: `${base(deal.to)} is not worth owing ${them} for` }
? { reply: 'accept', why: `${place(deal.to)} is worth having, and ${named(them)} may mean it.` }
: { reply: 'refuse', why: `${place(deal.to)} is not worth owing ${named(them)} for.` }
}
// They want my support. It costs me a unit's turn, and buys goodwill.
const cost = desire(pos, mind.power, deal.to)
const worth = believe * (FRIEND + NEIGHBOUR)
return worth > cost
? { reply: 'accept', why: `${them} is worth more to me than ${base(deal.to)}` }
: { reply: 'refuse', why: `I want ${base(deal.to)} myself` }
? { reply: 'accept', why: `${named(them)} is worth more to me than ${place(deal.to)}.` }
: { reply: 'refuse', why: `I want ${place(deal.to)} for myself.` }
}
if (deal.kind === 'dmz') {
const mine = desire(pos, mind.power, deal.province)
const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR
return relief * believe > mine
? { reply: 'accept', why: `keeping ${base(deal.province)} empty suits me` }
: { reply: 'refuse', why: `I have plans for ${base(deal.province)}` }
? { reply: 'accept', why: `Keeping ${place(deal.province)} empty suits me.` }
: { reply: 'refuse', why: `I have plans for ${place(deal.province)}.` }
}
const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR * believe
const behind = standing(pos, them) - standing(pos, mind.power)
return relief > 0 && behind < 3 * 100
? { reply: 'accept', why: `a quiet border with ${them} is worth more than the fight` }
: { reply: 'refuse', why: `${them} is either no threat or too far ahead to be trusted` }
? { reply: 'accept', why: `A quiet border with ${named(them)} is worth more than the fight.` }
: { reply: 'refuse', why: `${named(them)} is either no threat, or too far ahead to trust.` }
}
/** How many of our centres this power is standing next to. */
@@ -451,7 +460,7 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
to: target.to,
},
},
says: `Support my ${base(target.from)} into ${target.id} and it is mine this turn.`,
says: `Support my ${place(target.from)} into ${place(target.id)}, and it is mine this turn.`,
})
}
@@ -471,7 +480,7 @@ export function propose(pos: Position, mind: Mind, turn: number): Overture[] {
turn,
deal: { kind: 'dmz', province: between },
},
says: `Neither of us needs ${between}. Leave it empty and we both look elsewhere.`,
says: `Neither of us needs ${place(between)}. Leave it empty and we both look elsewhere.`,
})
}
+1 -1
View File
@@ -197,7 +197,7 @@ describe('the press, over a turn', () => {
// Austria promised Galicia would stay empty and marches straight in.
const orders: Order[] = [{ type: 'move', at: 'vie', to: 'gal', power: 'austria' }]
const after = resolveOrders(g, PLAYER, orders)
expect(after.log.join(' ')).toMatch(/austria broke its word/)
expect(after.log.join(' ')).toMatch(/Austria broke its word/)
expect(look(after.ledger, 'russia', 'austria').broken).toBe(1)
})
+21 -6
View File
@@ -192,7 +192,10 @@ export function resolveOrders(
const verdicts = judge(deal, byPower, g.board, g.own)
ledger = remember(ledger, verdicts)
for (const v of verdicts) {
if (!v.kept) broken.push(`${v.power} broke its word: ${v.why}.`)
if (!v.kept) {
const who = v.power[0]!.toUpperCase() + v.power.slice(1)
broken.push(`${who} broke its word: ${v.why}.`)
}
}
}
@@ -231,7 +234,11 @@ export function resolveRetreats(
const { board, disbanded } = applyRetreats(g.board, g.outcome, orders)
const log = [
...g.log,
...disbanded.map((u) => `${u.power} loses its ${u.type} in ${base(u.at)}: nowhere to go.`),
...disbanded.map(
(u) =>
`${u.power[0]!.toUpperCase()}${u.power.slice(1)} loses a ${u.type} in ` +
`${PROVINCES[base(u.at)]!.name}: nowhere to go.`,
),
]
return afterRetreats({ ...g, board, log, outcome: null })
}
@@ -254,8 +261,12 @@ function afterRetreats(g: Game): Game {
const log = [
...g.log,
...gone.map((p) => `${p} is finished: no centres left.`),
...(winner ? [`${winner} holds ${centreCount(own, winner)} centres. That is the game.`] : []),
...gone.map((p) => `${p[0]!.toUpperCase()}${p.slice(1)} is finished: no centres left.`),
...(winner
? [
`${winner[0]!.toUpperCase()}${winner.slice(1)} holds ${centreCount(own, winner)} centres. That is the game.`,
]
: []),
]
if (winner) return { ...g, own, out, winner, phase: 'over', log }
@@ -344,15 +355,19 @@ export function resolveBuilds(
// ------------------------------------------------------------------ saying
/** The turn, in the words a player would use for it. */
function report(outcome: Outcome, orders: readonly Order[]): string[] {
const said: string[] = []
const name = (id: string) => PROVINCES[base(id)]!.name
for (const o of orders) {
if (o.type !== 'move') continue
const at = base(o.at)
if (outcome.success.get(at)) said.push(`${at} takes ${base(o.to)}.`)
if (outcome.success.get(at)) said.push(`${name(at)} takes ${name(o.to)}.`)
}
for (const [at, d] of outcome.dislodged) {
said.push(`${d.unit.power}'s ${d.unit.type} is thrown out of ${at}.`)
const who = d.unit.power[0]!.toUpperCase() + d.unit.power.slice(1)
said.push(`${who} is thrown out of ${name(at)}.`)
}
if (said.length === 0) said.push('Nothing moved.')
return said