Half a convoy looked exactly like a whole one
Multi-fleet convoys work and always did -- London to Spain across the Channel and the Mid-Atlantic with both fleets carrying, which is what the fifty DATC convoy cases are about. What did not work was finding that out from inside the game. A convoy takes as many orders as there are seas to cross, and the rules give two different answers to writing fewer. With no fleet ordered at all the move is illegal -- legality is decided on the board alone -- and the panel reddens it. With one fleet of a two-fleet chain the move is perfectly legal and simply fails, silently, a turn later, with nothing on screen to tell it apart from having been blocked. From the chair where the orders are written those are one complaint: nobody is carrying this. And it is worse than it looks, because the board deliberately offers crossings that run through other powers' fleets, which cannot be ordered at all, only asked for -- so a player can write a crossing that no order of theirs could ever complete. The panel now asks the question that matters rather than the one the rules happen to answer: given everything on the table, is there a chain that gets this army there? When there is not it says which kind of not, in amber rather than the red an illegal order gets, because a legal order that will fail is a different thing to tell somebody.
This commit is contained in:
+14
-1
@@ -222,7 +222,7 @@ header select {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.written li { display: flex; align-items: center; gap: 6px; }
|
||||
.written li { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 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; }
|
||||
@@ -506,3 +506,16 @@ header select {
|
||||
.said li { margin: 2px 0; }
|
||||
|
||||
.watch { margin-top: 8px; }
|
||||
|
||||
/*
|
||||
* A crossing short of an escort. Amber rather than the red an illegal order
|
||||
* gets: this order is legal and will simply fail, which is a different thing
|
||||
* to tell somebody and deserves a different colour.
|
||||
*/
|
||||
.written li.unescorted span { color: #e8c46a; }
|
||||
.written li em {
|
||||
flex-basis: 100%;
|
||||
font-style: normal;
|
||||
font-size: 0.76rem;
|
||||
color: #b99a4e;
|
||||
}
|
||||
|
||||
+12
@@ -32,6 +32,7 @@ import {
|
||||
convoyable,
|
||||
supportTargets,
|
||||
supportable,
|
||||
unescorted,
|
||||
} from './game/targets'
|
||||
import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn'
|
||||
import './App.css'
|
||||
@@ -224,6 +225,16 @@ export default function App() {
|
||||
|
||||
const illegal = useMemo(() => validate(units, [...orders.values()]).illegal, [units, orders])
|
||||
|
||||
/*
|
||||
* Crossings with no complete chain of convoy orders behind them.
|
||||
*
|
||||
* Separate from `illegal` because it is a different complaint. The rules
|
||||
* are content with half a chain -- the move is legal and simply fails --
|
||||
* and a player cannot tell that from having been blocked. See
|
||||
* `unescorted` in the rules.
|
||||
*/
|
||||
const waiting = useMemo(() => unescorted(units, [...orders.values()]), [units, orders])
|
||||
|
||||
/** Yes or no to somebody's approach. Neither answer binds anybody. */
|
||||
const answer = (overture: Overture, yes: boolean) => {
|
||||
if (synth.sfxOn) synth.telegraph(yes)
|
||||
@@ -522,6 +533,7 @@ export default function App() {
|
||||
orders={orders}
|
||||
step={step}
|
||||
illegal={illegal}
|
||||
waiting={waiting}
|
||||
bySea={bySea.size > 0}
|
||||
onAsk={(kind) => setStep(step.kind === 'idle' ? step : ({ kind, at: step.at } as Step))}
|
||||
onClear={clear}
|
||||
|
||||
@@ -39,6 +39,7 @@ export function OrderPanel({
|
||||
orders,
|
||||
step,
|
||||
illegal,
|
||||
waiting,
|
||||
bySea,
|
||||
onAsk,
|
||||
onClear,
|
||||
@@ -48,6 +49,11 @@ export function OrderPanel({
|
||||
orders: ReadonlyMap<string, Order>
|
||||
step: Step
|
||||
illegal: ReadonlySet<string>
|
||||
/**
|
||||
* Crossings still short of a convoy. Not the same complaint as an illegal
|
||||
* order and not shown as one: these are legal, and will simply fail.
|
||||
*/
|
||||
waiting?: ReadonlySet<string>
|
||||
/** Whether any of the offered destinations needs a fleet to get there. */
|
||||
bySea?: boolean
|
||||
onAsk: (kind: Step['kind']) => void
|
||||
@@ -86,11 +92,18 @@ export function OrderPanel({
|
||||
|
||||
<ul className="written">
|
||||
{[...orders.entries()].map(([at, order]) => (
|
||||
<li key={at} className={illegal.has(at) ? 'bad' : ''}>
|
||||
<li key={at} className={illegal.has(at) ? 'bad' : waiting?.has(at) ? 'unescorted' : ''}>
|
||||
<span>{say(order, units.get(at))}</span>
|
||||
<button className="drop" onClick={() => onClear(at)} title="Take it back">
|
||||
×
|
||||
</button>
|
||||
{waiting?.has(at) && (
|
||||
<em>
|
||||
{illegal.has(at)
|
||||
? 'no fleet has been ordered to carry it'
|
||||
: 'ordered to carry it, but not the whole way'}
|
||||
</em>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{orders.size === 0 && <li className="dim">Nothing ordered. Everybody holds.</li>}
|
||||
@@ -109,7 +122,7 @@ function hint(step: Step, bySea: boolean): string {
|
||||
return ''
|
||||
case 'move':
|
||||
return bySea
|
||||
? 'Click where it should go. The coasts in blue need a fleet to carry it — order the convoy too.'
|
||||
? 'Click where it should go. The coasts in blue need carrying — order every fleet along the way, not just the first.'
|
||||
: 'Click where it should go.'
|
||||
case 'support':
|
||||
return step.from === undefined
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Power } from './map'
|
||||
import { boardFrom, type Unit } from './orders'
|
||||
import { boardFrom, validate, type Order, type Unit } from './orders'
|
||||
import {
|
||||
convoyDestinations,
|
||||
convoyTargets,
|
||||
convoyable,
|
||||
supportTargets,
|
||||
supportable,
|
||||
unescorted,
|
||||
} from './targets'
|
||||
|
||||
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
|
||||
@@ -139,3 +140,38 @@ describe('where an army may be carried', () => {
|
||||
expect(convoyDestinations(board, board.get('mun')!).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a crossing nobody was asked to escort', () => {
|
||||
const chain = () => boardFrom([A('england', 'lon'), F('england', 'eng'), F('england', 'mao')])
|
||||
const cross: Order = { type: 'move', at: 'lon', to: 'spa', viaConvoy: true }
|
||||
const carry = (at: string): Order => ({ type: 'convoy', at, from: 'lon', to: 'spa' })
|
||||
|
||||
it('is named when only half the chain has been ordered', () => {
|
||||
// The rules are content with this: legality is decided on the board
|
||||
// alone, so the move is legal and merely fails. The player cannot tell
|
||||
// that from being blocked, which is the whole reason this exists.
|
||||
const board = chain()
|
||||
expect(validate(board, [cross, carry('eng')]).illegal.size).toBe(0)
|
||||
expect(unescorted(board, [cross, carry('eng')])).toEqual(new Set(['lon']))
|
||||
})
|
||||
|
||||
it('is not named once the whole chain is ordered', () => {
|
||||
const board = chain()
|
||||
expect(unescorted(board, [cross, carry('eng'), carry('mao')]).size).toBe(0)
|
||||
})
|
||||
|
||||
it('says nothing about an army walking', () => {
|
||||
const board = boardFrom([A('germany', 'mun'), F('england', 'nth')])
|
||||
expect(unescorted(board, [{ type: 'move', at: 'mun', to: 'ruh' }]).size).toBe(0)
|
||||
})
|
||||
|
||||
it('names a crossing with no convoy at all, which the rules only redden', () => {
|
||||
// The rules call this illegal and the panel reddens it, but red alone
|
||||
// does not say what is missing -- and the board offers crossings that
|
||||
// run through other powers' fleets, which cannot be ordered at all.
|
||||
const board = boardFrom([A('england', 'lon'), F('france', 'nth')])
|
||||
const cross: Order = { type: 'move', at: 'lon', to: 'nwy', viaConvoy: true }
|
||||
expect(validate(board, [cross]).illegal).toEqual(new Set(['lon']))
|
||||
expect(unescorted(board, [cross])).toEqual(new Set(['lon']))
|
||||
})
|
||||
})
|
||||
|
||||
+41
-1
@@ -1,6 +1,14 @@
|
||||
import { reachableFrom } from './layout'
|
||||
import { PROVINCES, base } from './map'
|
||||
import { coastalSeas, coastsThroughFleets, type Board, type Unit } from './orders'
|
||||
import {
|
||||
coastalSeas,
|
||||
coastsThroughFleets,
|
||||
convoyRoute,
|
||||
validate,
|
||||
type Board,
|
||||
type Order,
|
||||
type Unit,
|
||||
} from './orders'
|
||||
|
||||
/**
|
||||
* What each half of an order may be clicked on.
|
||||
@@ -92,3 +100,35 @@ export function convoyTargets(board: Board, unit: Unit, from: string): Set<strin
|
||||
out.delete(base(from))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossings nobody has been asked to escort.
|
||||
*
|
||||
* A convoy takes as many orders as there are seas to cross, and the player
|
||||
* writing them needs one answer where the rules give two. Order an army
|
||||
* across with no fleet told to carry it and the order is *illegal* -- the
|
||||
* panel already reddens it. Order it with one fleet of a two-fleet chain and
|
||||
* the order is perfectly legal, because legality is decided on the board
|
||||
* alone; it simply fails, silently, a turn later.
|
||||
*
|
||||
* From the writer's chair those are the same complaint: nobody is carrying
|
||||
* this. Worse, the board offers crossings that run through *other people's*
|
||||
* fleets -- deliberately, since that is what the talking is for -- and those
|
||||
* cannot be ordered at all, only asked for. So this asks the one question
|
||||
* that matters: given every order on the table, is there a chain that
|
||||
* actually gets this army there?
|
||||
*/
|
||||
export function unescorted(board: Board, given: readonly Order[]): Set<string> {
|
||||
const { orders } = validate(board, given)
|
||||
const out = new Set<string>()
|
||||
for (const order of given) {
|
||||
if (order.type !== 'move') continue
|
||||
const at = base(order.at)
|
||||
const unit = board.get(at)
|
||||
if (unit?.type !== 'army') continue
|
||||
// Only a crossing can want an escort; a march is its own arrangement.
|
||||
if (reachableFrom(unit).map(base).includes(base(order.to))) continue
|
||||
if (convoyRoute(board, orders, unit.at, order.to) === null) out.add(at)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user