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; } .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 ------------------------------------------------------- */ /* --- the side ------------------------------------------------------- */
.side { .side {
@@ -222,7 +303,9 @@ header select {
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; 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; } .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 { Board, COLOURS, POWER_NAMES } from './components/Board'
import { OrderPanel, type Step } from './components/OrderPanel' 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 { reachableFrom } from './game/layout'
import { OPENING, POWERS, PROVINCES, base, type Power } from './game/map' import { POWERS, PROVINCES, base, type Power } from './game/map'
import { boardFrom, canStep, validate, type Order, type Unit } from './game/orders' import { canStep, validate, type Order, type Unit } from './game/orders'
import { centreCount, openingOwnership } from './game/turn' import type { Proposal } from './game/press'
import { centreCount } from './game/turn'
import './App.css' import './App.css'
/** /**
* Spring 1901, with orders. * The game, being played.
* *
* The whole interaction is clicks on the board, because that is where the * Everything is clicks on the board or answers in the panel, because those
* question is. A unit is selected, a verb is chosen, and the provinces the * are the only two things this game asks of anybody: where are your units
* next click will accept light up -- so a player never types a move that the * going, and do you mean what you told Russia.
* 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() { export default function App() {
const [power, setPower] = useState<Power>('austria') 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 [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 units = useMemo(() => { /*
const all: Unit[] = [] * The talking happens once per orders phase. Keeping a note of which turn
for (const p of POWERS) { * has been negotiated matters more than it looks: agreeing a deal changes
for (const at of OPENING[p].armies) all.push({ power: p, type: 'army', at }) * the game, and a re-run on every change would have the powers proposing
for (const at of OPENING[p].fleets) all.push({ power: p, type: 'fleet', at }) * to each other in a loop for ever.
} */
return boardFrom(all) 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(() => { const offering = useMemo(() => {
if (step.kind === 'idle') return new Set<string>() if (step.kind === 'idle') return new Set<string>()
const unit = units.get(step.at) const unit = units.get(step.at)
if (!unit) return new Set<string>() if (!unit) return new Set<string>()
if (step.kind === 'move') return new Set(reachableFrom(unit).map(base)) if (step.kind === 'move') return new Set(reachableFrom(unit).map(base))
if (step.kind === 'support') { if (step.kind === 'support') {
if (step.from === undefined) { if (step.from === undefined) {
// Anybody I could reach: I can prop them up where they stand, or return new Set([...units.keys()].filter((p) => p !== step.at && canReach(unit, p)))
// 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) const helped = units.get(step.from)
if (!helped) return new Set<string>() 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( return new Set(
reachableFrom(helped) reachableFrom(helped)
.map(base) .map(base)
.filter((p) => canReach(unit, p) || p === step.from), .filter((p) => canReach(unit, p) || p === step.from),
) )
} }
if (step.from === undefined) { if (step.from === undefined) {
return new Set( return new Set(
[...units.entries()] [...units.entries()]
@@ -82,32 +90,20 @@ export default function App() {
const click = (province: string) => { const click = (province: string) => {
const here = units.get(province) const here = units.get(province)
// Starting again: clicking one of my own units always selects it.
if (step.kind === 'idle' || !offering.has(province)) { if (step.kind === 'idle' || !offering.has(province)) {
if (here?.power === power) setStep({ kind: 'move', at: province }) if (here?.power === power) setStep({ kind: 'move', at: province })
else setStep({ kind: 'idle' }) else setStep({ kind: 'idle' })
return return
} }
if (step.kind === 'move') { if (step.kind === 'move') {
write({ type: 'move', at: step.at, to: coastOf(units.get(step.at)!, province), power }) write({ type: 'move', at: step.at, to: coastOf(units.get(step.at)!, province), power })
return return
} }
if (step.kind === 'support') { if (step.kind === 'support') {
if (step.from === undefined) { if (step.from === undefined) return setStep({ ...step, from: province })
setStep({ ...step, from: province }) return write({ type: 'support', at: step.at, from: step.from, to: province, power })
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 })
write({ type: 'convoy', at: step.at, from: step.from, to: province, power }) write({ type: 'convoy', at: step.at, from: step.from, to: province, power })
} }
@@ -120,31 +116,77 @@ export default function App() {
setStep({ kind: 'idle' }) setStep({ kind: 'idle' })
} }
// What the rules make of the orders so far, live. A player should find out const illegal = useMemo(() => validate(units, [...orders.values()]).illegal, [units, orders])
// that a support is impossible while writing it, not after the turn.
const illegal = useMemo( /** Yes or no to somebody's approach. Neither answer binds anybody. */
() => validate(units, [...orders.values()]).illegal, const answer = (overture: Overture, yes: boolean) => {
[units, orders], 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 mine = [...units.entries()].filter(([, u]) => u.power === power)
const season = game.season === 'spring' ? 'Spring' : 'Autumn'
return ( return (
<div className="app"> <div className="app">
<header> <header>
<h1>Great Powers</h1> <h1>Great Powers</h1>
<p className="dim"> <p className="dim">
Spring 1901 &mdash; playing{' '} {game.phase === 'over'
<select value={power} onChange={(e) => { ? game.winner
setPower(e.target.value as Power) ? `${POWER_NAMES[game.winner]} has eighteen centres. That is the game.`
setOrders(new Map()) : `Called at the end of ${game.year}. A draw between the survivors.`
setStep({ kind: 'idle' }) : `${season} ${game.year} of ${LAST_YEAR} — playing `}
}}> {game.phase !== 'over' && (
{POWERS.map((p) => ( <select
<option key={p} value={p}>{POWER_NAMES[p]}</option> value={power}
))} onChange={(e) => {
</select> setPower(e.target.value as Power)
. {mine.length - orders.size} of {mine.length} units still without orders. 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> </p>
</header> </header>
@@ -162,7 +204,7 @@ export default function App() {
<aside className="side"> <aside className="side">
<ul className="powers"> <ul className="powers">
{POWERS.map((p) => ( {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] }} /> <span className="swatch" style={{ background: COLOURS[p] }} />
{POWER_NAMES[p]} {POWER_NAMES[p]}
<span className="count">{centreCount(own, p)}</span> <span className="count">{centreCount(own, p)}</span>
@@ -170,34 +212,43 @@ export default function App() {
))} ))}
</ul> </ul>
<PressPanel
power={power}
turn={turnOf(game)}
ledger={game.ledger}
asked={asked}
agreements={game.agreements}
orders={orders}
onAnswer={answer}
onAsk={ask}
/>
<OrderPanel <OrderPanel
units={units} units={units}
orders={orders} orders={orders}
step={step} step={step}
illegal={illegal} illegal={illegal}
onAsk={(kind) => onAsk={(kind) => setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))}
setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))
}
onClear={clear} onClear={clear}
onSubmit={() => undefined} onSubmit={submit}
/> />
<ul className="log">
{game.log.slice(-6).map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</aside> </aside>
</div> </div>
) )
} }
/** Can this unit reach that province, by any coast of it? */
function canReach(unit: Unit, province: string): boolean { function canReach(unit: Unit, province: string): boolean {
if (canStep(unit, province)) return true if (canStep(unit, province)) return true
const coasts = PROVINCES[province]?.coasts const coasts = PROVINCES[province]?.coasts
return coasts !== undefined && coasts.some((c) => canStep(unit, `${province}/${c}`)) 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 { function coastOf(unit: Unit, province: string): string {
const coasts = PROVINCES[province]?.coasts const coasts = PROVINCES[province]?.coasts
if (unit.type === 'army' || !coasts) return province 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).toBeDefined()
expect(ask!.proposal.to).toBe('england') 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', () => { it('never asks the power it is attacking to help it', () => {
+19 -10
View File
@@ -321,6 +321,15 @@ export interface Answer {
why: string 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. * 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) { if (deal.mover === mind.power) {
const worth = desire(pos, mind.power, deal.to) * believe const worth = desire(pos, mind.power, deal.to) * believe
return worth > FRIEND return worth > FRIEND
? { reply: 'accept', why: `${base(deal.to)} is worth having and ${them} may mean it` } ? { reply: 'accept', why: `${place(deal.to)} is worth having, and ${named(them)} may mean it.` }
: { reply: 'refuse', why: `${base(deal.to)} is not worth owing ${them} for` } : { 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. // They want my support. It costs me a unit's turn, and buys goodwill.
const cost = desire(pos, mind.power, deal.to) const cost = desire(pos, mind.power, deal.to)
const worth = believe * (FRIEND + NEIGHBOUR) const worth = believe * (FRIEND + NEIGHBOUR)
return worth > cost return worth > cost
? { reply: 'accept', why: `${them} is worth more to me than ${base(deal.to)}` } ? { reply: 'accept', why: `${named(them)} is worth more to me than ${place(deal.to)}.` }
: { reply: 'refuse', why: `I want ${base(deal.to)} myself` } : { reply: 'refuse', why: `I want ${place(deal.to)} for myself.` }
} }
if (deal.kind === 'dmz') { if (deal.kind === 'dmz') {
const mine = desire(pos, mind.power, deal.province) const mine = desire(pos, mind.power, deal.province)
const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR
return relief * believe > mine return relief * believe > mine
? { reply: 'accept', why: `keeping ${base(deal.province)} empty suits me` } ? { reply: 'accept', why: `Keeping ${place(deal.province)} empty suits me.` }
: { reply: 'refuse', why: `I have plans for ${base(deal.province)}` } : { reply: 'refuse', why: `I have plans for ${place(deal.province)}.` }
} }
const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR * believe const relief = threatFrom(pos, them, mind.power) * NEIGHBOUR * believe
const behind = standing(pos, them) - standing(pos, mind.power) const behind = standing(pos, them) - standing(pos, mind.power)
return relief > 0 && behind < 3 * 100 return relief > 0 && behind < 3 * 100
? { reply: 'accept', why: `a quiet border with ${them} is worth more than the fight` } ? { reply: 'accept', why: `A quiet border with ${named(them)} is worth more than the fight.` }
: { reply: 'refuse', why: `${them} is either no threat or too far ahead to be trusted` } : { 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. */ /** 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, 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, turn,
deal: { kind: 'dmz', province: between }, 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. // Austria promised Galicia would stay empty and marches straight in.
const orders: Order[] = [{ type: 'move', at: 'vie', to: 'gal', power: 'austria' }] const orders: Order[] = [{ type: 'move', at: 'vie', to: 'gal', power: 'austria' }]
const after = resolveOrders(g, PLAYER, orders) 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) 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) const verdicts = judge(deal, byPower, g.board, g.own)
ledger = remember(ledger, verdicts) ledger = remember(ledger, verdicts)
for (const v of 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 { board, disbanded } = applyRetreats(g.board, g.outcome, orders)
const log = [ const log = [
...g.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 }) return afterRetreats({ ...g, board, log, outcome: null })
} }
@@ -254,8 +261,12 @@ function afterRetreats(g: Game): Game {
const log = [ const log = [
...g.log, ...g.log,
...gone.map((p) => `${p} is finished: no centres left.`), ...gone.map((p) => `${p[0]!.toUpperCase()}${p.slice(1)} is finished: no centres left.`),
...(winner ? [`${winner} holds ${centreCount(own, winner)} centres. That is the game.`] : []), ...(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 } if (winner) return { ...g, own, out, winner, phase: 'over', log }
@@ -344,15 +355,19 @@ export function resolveBuilds(
// ------------------------------------------------------------------ saying // ------------------------------------------------------------------ saying
/** The turn, in the words a player would use for it. */
function report(outcome: Outcome, orders: readonly Order[]): string[] { function report(outcome: Outcome, orders: readonly Order[]): string[] {
const said: string[] = [] const said: string[] = []
const name = (id: string) => PROVINCES[base(id)]!.name
for (const o of orders) { for (const o of orders) {
if (o.type !== 'move') continue if (o.type !== 'move') continue
const at = base(o.at) 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) { 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.') if (said.length === 0) said.push('Nothing moved.')
return said return said