The adjudicator
Kruijswijk's resolver: work each order out from the others, and when that turns out to be circular -- which it genuinely can be, this game has real paradoxes in it -- guess, check whether both guesses agree, and fall back to a stated rule when they do not. A ring of units all moving into each other all move. A convoy paradox is settled by Szykman's rule, which is a convention rather than a deduction and is written down as one. Four strengths do the work: attack, hold, defend and prevent. A move has to beat the right one of them strictly, so an unsupported attack never takes a supported province and a tie is always a bounce. Worth noting what falls out rather than being written down. A unit cannot drive out a countryman, so a move against one has attack strength zero, so it cuts no support -- which is why a power cannot cut its own support. That is a consequence of Calhamer's rule, not a separate one, and the comment says so where the zero is returned. Twenty-one cases, all written out in full so a reader can check them by hand.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adjudicate } from './adjudicate'
|
||||
import { boardFrom, type Order, type Unit } from './orders'
|
||||
import type { Power } from './map'
|
||||
|
||||
/**
|
||||
* The adjudicator, against the cases that actually catch people out.
|
||||
*
|
||||
* These are the classic situations the published test cases are built from:
|
||||
* ties bounce, support is cut by anything with weight behind it, a country
|
||||
* cannot throw out its own units, and a convoy is only as good as the fleets
|
||||
* under it. Each one is written out in full rather than generated, because
|
||||
* the point of a case like this is that a reader can check it by hand.
|
||||
*/
|
||||
|
||||
const A = (power: Power, at: string): Unit => ({ power, type: 'army', at })
|
||||
const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at })
|
||||
|
||||
const mv = (at: string, to: string, viaConvoy = false): Order => ({
|
||||
type: 'move',
|
||||
at,
|
||||
to,
|
||||
viaConvoy,
|
||||
})
|
||||
const sup = (at: string, from: string, to: string): Order => ({ type: 'support', at, from, to })
|
||||
const hold = (at: string): Order => ({ type: 'hold', at })
|
||||
const cvy = (at: string, from: string, to: string): Order => ({ type: 'convoy', at, from, to })
|
||||
|
||||
const run = (units: Unit[], orders: Order[]) => adjudicate(boardFrom(units), orders)
|
||||
|
||||
// --------------------------------------------------------------- the basics
|
||||
|
||||
describe('what a unit may even attempt', () => {
|
||||
it('refuses a move to a province it does not border', () => {
|
||||
const r = run([A('france', 'par')], [mv('par', 'mun')])
|
||||
expect(r.success.get('par')).toBe(false)
|
||||
})
|
||||
|
||||
it('will not march an army into the sea, or sail a fleet inland', () => {
|
||||
const r = run([A('france', 'bre'), F('germany', 'kie')], [mv('bre', 'eng'), mv('kie', 'mun')])
|
||||
expect(r.success.get('bre')).toBe(false)
|
||||
expect(r.success.get('kie')).toBe(false)
|
||||
})
|
||||
|
||||
it('moves a unit into an empty province', () => {
|
||||
const r = run([A('france', 'par')], [mv('par', 'bur')])
|
||||
expect(r.success.get('par')).toBe(true)
|
||||
expect(r.dislodged.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------- the balance
|
||||
|
||||
describe('strength', () => {
|
||||
it('bounces two equal attacks and leaves the province empty', () => {
|
||||
const r = run([A('france', 'par'), A('germany', 'mun')], [mv('par', 'bur'), mv('mun', 'bur')])
|
||||
expect(r.success.get('par')).toBe(false)
|
||||
expect(r.success.get('mun')).toBe(false)
|
||||
expect(r.bounced.has('bur')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not let an unsupported attack beat a supported defence', () => {
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('germany', 'mun'), A('germany', 'ruh')],
|
||||
[mv('bur', 'mun'), hold('mun'), sup('ruh', 'mun', 'mun')],
|
||||
)
|
||||
expect(r.success.get('bur')).toBe(false)
|
||||
expect(r.dislodged.size).toBe(0)
|
||||
})
|
||||
|
||||
it('dislodges when the attack is supported and the defence is not', () => {
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun')],
|
||||
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun')],
|
||||
)
|
||||
expect(r.success.get('bur')).toBe(true)
|
||||
expect(r.dislodged.get('mun')?.attackedFrom).toBe('bur')
|
||||
})
|
||||
|
||||
it('needs more than equal support, because a tie is a bounce', () => {
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'ruh')],
|
||||
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), sup('ruh', 'mun', 'mun')],
|
||||
)
|
||||
expect(r.success.get('bur')).toBe(false)
|
||||
expect(r.dislodged.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------- your own men
|
||||
|
||||
describe('your own units', () => {
|
||||
it('cannot be driven out by you, however much you push', () => {
|
||||
const r = run(
|
||||
[A('germany', 'ber'), A('germany', 'mun'), A('germany', 'sil')],
|
||||
[mv('ber', 'mun'), hold('mun'), sup('sil', 'ber', 'mun')],
|
||||
)
|
||||
expect(r.success.get('ber')).toBe(false)
|
||||
expect(r.dislodged.size).toBe(0)
|
||||
})
|
||||
|
||||
it('cannot be driven out by a foreigner you are helping either', () => {
|
||||
// Germany supports France into Munich, where a German army stands.
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('germany', 'ruh'), A('germany', 'mun')],
|
||||
[mv('bur', 'mun'), sup('ruh', 'bur', 'mun'), hold('mun')],
|
||||
)
|
||||
expect(r.success.get('bur')).toBe(false)
|
||||
expect(r.dislodged.size).toBe(0)
|
||||
})
|
||||
|
||||
it('do not cut your own support, since they were never a threat', () => {
|
||||
/*
|
||||
* Germany supports France's attack on Munich and pushes a second German
|
||||
* army at the supporting unit. That push has no weight -- it cannot
|
||||
* dislodge a countryman -- so it cuts nothing, and the support stands.
|
||||
*/
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('germany', 'ruh'), A('germany', 'kie'), A('italy', 'mun')],
|
||||
[mv('bur', 'mun'), sup('ruh', 'bur', 'mun'), mv('kie', 'ruh'), hold('mun')],
|
||||
)
|
||||
expect(r.success.get('ruh')).toBe(true)
|
||||
expect(r.success.get('bur')).toBe(true)
|
||||
expect(r.dislodged.get('mun')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------ support
|
||||
|
||||
describe('cutting support', () => {
|
||||
it('is cut by an attack from anywhere else', () => {
|
||||
// Picardy, not Ruhr: Ruhr does not border Paris, and an attack that
|
||||
// cannot arrive cuts nothing.
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'pic')],
|
||||
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), mv('pic', 'par')],
|
||||
)
|
||||
expect(r.success.get('par')).toBe(false)
|
||||
expect(r.success.get('bur')).toBe(false)
|
||||
})
|
||||
|
||||
it('is not cut by an attack that could never arrive', () => {
|
||||
// The same order from Ruhr, which does not border Paris at all.
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'ruh')],
|
||||
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), mv('ruh', 'par')],
|
||||
)
|
||||
expect(r.success.get('par')).toBe(true)
|
||||
expect(r.dislodged.get('mun')).toBeDefined()
|
||||
})
|
||||
|
||||
it('is not cut by an attack from the province being supported into', () => {
|
||||
// Munich attacks the supporter; Munich is what the support is aimed at,
|
||||
// so the support holds and Munich is thrown out by it.
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun')],
|
||||
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), mv('mun', 'par')],
|
||||
)
|
||||
expect(r.success.get('par')).toBe(true)
|
||||
expect(r.success.get('bur')).toBe(true)
|
||||
expect(r.dislodged.get('mun')).toBeDefined()
|
||||
})
|
||||
|
||||
it('is cut by being thrown out, however the support was going', () => {
|
||||
const r = run(
|
||||
[A('france', 'bur'), A('france', 'par'), A('germany', 'mun'), A('germany', 'pic'), A('germany', 'bre')],
|
||||
[mv('bur', 'mun'), sup('par', 'bur', 'mun'), hold('mun'), mv('pic', 'par'), sup('bre', 'pic', 'par')],
|
||||
)
|
||||
expect(r.dislodged.get('par')).toBeDefined()
|
||||
expect(r.success.get('bur')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------- swaps, rings
|
||||
|
||||
describe('units changing places', () => {
|
||||
it('bounces two units trying to swap', () => {
|
||||
const r = run([A('france', 'par'), A('germany', 'bur')], [mv('par', 'bur'), mv('bur', 'par')])
|
||||
expect(r.success.get('par')).toBe(false)
|
||||
expect(r.success.get('bur')).toBe(false)
|
||||
expect(r.dislodged.size).toBe(0)
|
||||
})
|
||||
|
||||
it('lets a supported unit win a swap outright', () => {
|
||||
const r = run(
|
||||
[A('france', 'par'), A('france', 'pic'), A('germany', 'bur')],
|
||||
[mv('par', 'bur'), sup('pic', 'par', 'bur'), mv('bur', 'par')],
|
||||
)
|
||||
expect(r.success.get('par')).toBe(true)
|
||||
expect(r.dislodged.get('bur')).toBeDefined()
|
||||
})
|
||||
|
||||
it('turns a whole ring at once', () => {
|
||||
// Nobody dislodges anybody; they all shuffle round.
|
||||
const r = run(
|
||||
[A('austria', 'vie'), A('austria', 'bud'), A('austria', 'gal')],
|
||||
[mv('vie', 'bud'), mv('bud', 'gal'), mv('gal', 'vie')],
|
||||
)
|
||||
expect(r.success.get('vie')).toBe(true)
|
||||
expect(r.success.get('bud')).toBe(true)
|
||||
expect(r.success.get('gal')).toBe(true)
|
||||
expect(r.dislodged.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------- convoys
|
||||
|
||||
describe('convoys', () => {
|
||||
it('carries an army across water', () => {
|
||||
const r = run(
|
||||
[A('england', 'lon'), F('england', 'nth')],
|
||||
[mv('lon', 'bel'), cvy('nth', 'lon', 'bel')],
|
||||
)
|
||||
expect(r.success.get('lon')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the army where it started when the escort is sunk', () => {
|
||||
const r = run(
|
||||
[A('england', 'lon'), F('england', 'nth'), F('germany', 'hel'), F('germany', 'den')],
|
||||
[mv('lon', 'bel'), cvy('nth', 'lon', 'bel'), mv('hel', 'nth'), sup('den', 'hel', 'nth')],
|
||||
)
|
||||
expect(r.dislodged.get('nth')).toBeDefined()
|
||||
expect(r.success.get('lon')).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the long way round when one sea of two is lost', () => {
|
||||
const r = run(
|
||||
[
|
||||
A('england', 'lon'),
|
||||
F('england', 'nth'),
|
||||
F('england', 'eng'),
|
||||
F('france', 'iri'),
|
||||
F('france', 'wal'),
|
||||
],
|
||||
[
|
||||
mv('lon', 'bel'),
|
||||
cvy('nth', 'lon', 'bel'),
|
||||
cvy('eng', 'lon', 'bel'),
|
||||
mv('iri', 'eng'),
|
||||
sup('wal', 'iri', 'eng'),
|
||||
],
|
||||
)
|
||||
// The Channel goes, but the North Sea alone still reaches Belgium.
|
||||
expect(r.dislodged.get('eng')).toBeDefined()
|
||||
expect(r.success.get('lon')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets an army walk instead when the water is not the only way', () => {
|
||||
// London to Yorkshire needs no convoy, so sinking the fleet changes nothing.
|
||||
const r = run(
|
||||
[A('england', 'lon'), F('england', 'nth'), F('germany', 'hel'), F('germany', 'den')],
|
||||
[mv('lon', 'yor'), cvy('nth', 'lon', 'yor'), mv('hel', 'nth'), sup('den', 'hel', 'nth')],
|
||||
)
|
||||
expect(r.success.get('lon')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,343 @@
|
||||
import { ARMY, base } from './map'
|
||||
import { canStep, convoyRoute, type Board, type Order, type Unit } from './orders'
|
||||
|
||||
/**
|
||||
* The adjudicator.
|
||||
*
|
||||
* This is the part that has to be exactly right rather than approximately
|
||||
* right, because a subtly wrong adjudicator plays perfectly well and is
|
||||
* simply a different game. The method is Lucas Kruijswijk's: work out each
|
||||
* order's outcome from the others, and when that turns out to be circular --
|
||||
* which it genuinely can be, this game has real paradoxes in it -- guess,
|
||||
* see whether both guesses agree, and fall back to a stated rule when they
|
||||
* do not.
|
||||
*
|
||||
* Four strengths do all the work, and every one of them is a number:
|
||||
*
|
||||
* - **attack**: how hard a move pushes, once supports are counted;
|
||||
* - **hold**: how hard the destination resists;
|
||||
* - **defend**: how hard a unit pushes back when the two are swapping;
|
||||
* - **prevent**: how hard a move stops anybody *else* taking the province,
|
||||
* even when it fails itself.
|
||||
*
|
||||
* A move succeeds when it beats the right one of those, strictly. Ties bounce,
|
||||
* which is the rule the whole game is balanced on.
|
||||
*/
|
||||
|
||||
export interface Dislodgement {
|
||||
unit: Unit
|
||||
/** Where the attacker came from. A unit may not retreat back into it. */
|
||||
attackedFrom: string
|
||||
}
|
||||
|
||||
export interface Outcome {
|
||||
/** Province -> did the order given there succeed. */
|
||||
success: Map<string, boolean>
|
||||
/** Province -> the unit thrown out of it. */
|
||||
dislodged: Map<string, Dislodgement>
|
||||
/** Provinces left empty by a bounce, which nobody may retreat into. */
|
||||
bounced: Set<string>
|
||||
}
|
||||
|
||||
type State = 'unresolved' | 'guessing' | 'resolved'
|
||||
|
||||
export function adjudicate(board: Board, orderList: readonly Order[]): Outcome {
|
||||
const orders = new Map<string, Order>()
|
||||
for (const o of orderList) orders.set(base(o.at), o)
|
||||
// A unit with no order holds; a unit with a nonsense order holds too.
|
||||
for (const p of board.keys()) if (!orders.has(p)) orders.set(p, { type: 'hold', at: p })
|
||||
|
||||
const state = new Map<string, State>()
|
||||
const result = new Map<string, boolean>()
|
||||
const dep: string[] = []
|
||||
for (const p of orders.keys()) state.set(p, 'unresolved')
|
||||
|
||||
const orderAt = (p: string) => orders.get(p)
|
||||
const unitAt = (p: string) => board.get(p)
|
||||
|
||||
/** Every move order aimed at this province. */
|
||||
const movesInto = (dest: string): string[] => {
|
||||
const out: string[] = []
|
||||
for (const [p, o] of orders) {
|
||||
if (o.type === 'move' && base(o.to) === dest) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Two units swapping places without a convoy under either of them. It is
|
||||
* its own case because neither can simply walk through the other: they are
|
||||
* measured against each other rather than against the ground.
|
||||
*/
|
||||
const headToHead = (p: string): string | null => {
|
||||
const o = orderAt(p)
|
||||
if (o?.type !== 'move') return null
|
||||
const dest = base(o.to)
|
||||
const other = orderAt(dest)
|
||||
if (other?.type !== 'move' || base(other.to) !== p) return null
|
||||
if (isConvoyed(p) || isConvoyed(dest)) return null
|
||||
return dest
|
||||
}
|
||||
|
||||
/** Is this move going over water rather than across a border? */
|
||||
function isConvoyed(p: string): boolean {
|
||||
const o = orderAt(p)
|
||||
const unit = unitAt(p)
|
||||
if (o?.type !== 'move' || unit?.type !== 'army') return false
|
||||
const overland = (ARMY[base(unit.at)] ?? []).includes(base(o.to))
|
||||
if (overland && !o.viaConvoy) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Fleets convoying that are being thrown out of their sea as we speak. */
|
||||
const brokenConvoys = (): Set<string> => {
|
||||
const gone = new Set<string>()
|
||||
for (const [p, o] of orders) {
|
||||
if (o.type === 'convoy' && !resolve(p)) gone.add(p)
|
||||
}
|
||||
return gone
|
||||
}
|
||||
|
||||
/** Can this move physically happen at all? Zero attack strength if not. */
|
||||
function hasPath(p: string): boolean {
|
||||
const o = orderAt(p)
|
||||
const unit = unitAt(p)
|
||||
if (o?.type !== 'move' || !unit) return false
|
||||
if (unit.type === 'fleet') return canStep(unit, o.to)
|
||||
if (!isConvoyed(p)) return canStep(unit, o.to)
|
||||
return convoyRoute(board, orders, unit.at, o.to, brokenConvoys()) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Supports for this move that actually count.
|
||||
*
|
||||
* `notFrom` drops supports belonging to the power being dislodged: you may
|
||||
* not help a foreigner throw your own unit out of a province.
|
||||
*/
|
||||
function supportsFor(p: string, notFrom: string | null): number {
|
||||
const o = orderAt(p)
|
||||
if (o?.type !== 'move') return 0
|
||||
let n = 0
|
||||
for (const [q, s] of orders) {
|
||||
if (s.type !== 'support') continue
|
||||
if (base(s.from) !== p || base(s.to) !== base(o.to)) continue
|
||||
if (notFrom !== null && unitAt(q)?.power === notFrom) continue
|
||||
if (resolve(q)) n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function supportsToHold(dest: string): number {
|
||||
let n = 0
|
||||
for (const [q, s] of orders) {
|
||||
if (s.type !== 'support') continue
|
||||
if (base(s.from) !== dest || base(s.to) !== dest) continue
|
||||
if (resolve(q)) n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function attackStrength(p: string): number {
|
||||
const o = orderAt(p)
|
||||
if (o?.type !== 'move' || !hasPath(p)) return 0
|
||||
const dest = base(o.to)
|
||||
const mover = unitAt(p)!
|
||||
const occupier = unitAt(dest)
|
||||
if (!occupier) return 1 + supportsFor(p, null)
|
||||
|
||||
const theirOrder = orderAt(dest)
|
||||
const swapping = headToHead(p) !== null
|
||||
if (theirOrder?.type === 'move' && !swapping && resolve(dest)) {
|
||||
// They left. Nobody is being dislodged, so nationality does not matter.
|
||||
return 1 + supportsFor(p, null)
|
||||
}
|
||||
|
||||
/*
|
||||
* You cannot drive out your own piece -- Calhamer's rule, so that nobody
|
||||
* can secure a retreat for themselves by force. Worth noticing what else
|
||||
* falls out of it: a unit moving against a countryman has attack strength
|
||||
* zero, and a move with no strength cuts no support. That is why a power
|
||||
* cannot cut its own support, and it is a consequence of this line rather
|
||||
* than a separate rule anybody has to remember.
|
||||
*/
|
||||
if (occupier.power === mover.power) return 0
|
||||
return 1 + supportsFor(p, occupier.power)
|
||||
}
|
||||
|
||||
function holdStrength(dest: string): number {
|
||||
if (!unitAt(dest)) return 0
|
||||
const o = orderAt(dest)
|
||||
if (o?.type === 'move') return resolve(dest) ? 0 : 1
|
||||
return 1 + supportsToHold(dest)
|
||||
}
|
||||
|
||||
const defendStrength = (p: string): number => 1 + supportsFor(p, null)
|
||||
|
||||
function preventStrength(p: string): number {
|
||||
if (!hasPath(p)) return 0
|
||||
const other = headToHead(p)
|
||||
if (other !== null && resolve(other)) return 0
|
||||
return 1 + supportsFor(p, null)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- one order
|
||||
|
||||
function adjudicateOne(p: string): boolean {
|
||||
const o = orderAt(p)!
|
||||
|
||||
if (o.type === 'hold') return true
|
||||
|
||||
if (o.type === 'convoy') {
|
||||
// A convoy carries on unless the fleet is thrown out of the sea.
|
||||
return !isDislodged(p)
|
||||
}
|
||||
|
||||
if (o.type === 'support') {
|
||||
if (isDislodged(p)) return false
|
||||
for (const q of movesInto(p)) {
|
||||
// An attack coming from the very province the support is aimed at
|
||||
// does not cut it. Everything else does, if it has any weight.
|
||||
if (q === base(o.to)) continue
|
||||
if (attackStrength(q) >= 1) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// A move.
|
||||
if (!hasPath(p)) return false
|
||||
const dest = base(o.to)
|
||||
const attack = attackStrength(p)
|
||||
|
||||
for (const rival of movesInto(dest)) {
|
||||
if (rival === p) continue
|
||||
if (attack <= preventStrength(rival)) return false
|
||||
}
|
||||
|
||||
const swap = headToHead(p)
|
||||
if (swap !== null) return attack > defendStrength(swap)
|
||||
return attack > holdStrength(dest)
|
||||
}
|
||||
|
||||
/** Is the unit standing here thrown out of it? */
|
||||
function isDislodged(p: string): boolean {
|
||||
if (!unitAt(p)) return false
|
||||
const own = orderAt(p)
|
||||
if (own?.type === 'move' && resolve(p)) return false
|
||||
return movesInto(p).some((q) => resolve(q))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- the resolver
|
||||
|
||||
/**
|
||||
* Kruijswijk's resolver.
|
||||
*
|
||||
* Guess that an order fails and work out what follows. If nothing depended
|
||||
* on the guess, that is the answer. If something did, guess the other way:
|
||||
* agreeing answers are the answer, and disagreeing ones mean a genuine
|
||||
* cycle, which the backup rule below settles.
|
||||
*/
|
||||
function resolve(p: string): boolean {
|
||||
const s = state.get(p)
|
||||
if (s === 'resolved') return result.get(p)!
|
||||
if (s === 'guessing') {
|
||||
if (!dep.includes(p)) dep.push(p)
|
||||
return result.get(p)!
|
||||
}
|
||||
|
||||
const mark = dep.length
|
||||
state.set(p, 'guessing')
|
||||
result.set(p, false)
|
||||
const first = adjudicateOne(p)
|
||||
|
||||
if (dep.length === mark) {
|
||||
state.set(p, 'resolved')
|
||||
result.set(p, first)
|
||||
return first
|
||||
}
|
||||
|
||||
if (dep[mark] !== p) {
|
||||
// Somebody above us is the one really guessing; report and let them ask.
|
||||
dep.push(p)
|
||||
result.set(p, first)
|
||||
return first
|
||||
}
|
||||
|
||||
while (dep.length > mark) state.set(dep.pop()!, 'unresolved')
|
||||
|
||||
state.set(p, 'guessing')
|
||||
result.set(p, true)
|
||||
const second = adjudicateOne(p)
|
||||
|
||||
if (first === second) {
|
||||
while (dep.length > mark) state.set(dep.pop()!, 'unresolved')
|
||||
state.set(p, 'resolved')
|
||||
result.set(p, first)
|
||||
return first
|
||||
}
|
||||
|
||||
backup(mark)
|
||||
return resolve(p)
|
||||
}
|
||||
|
||||
/**
|
||||
* A cycle that does not settle. There are exactly two kinds in this game
|
||||
* and they are settled differently:
|
||||
*
|
||||
* - **a ring of units all moving into each other's provinces.** Nobody is
|
||||
* dislodging anybody; they all shuffle round, so they all succeed.
|
||||
* - **a convoy paradox**, where whether a convoy survives depends on the
|
||||
* move the convoy is carrying. Szykman's rule: the convoyed move fails
|
||||
* and everything else is worked out from there. It is a convention
|
||||
* rather than a deduction, which is why it is written down here rather
|
||||
* than buried in the arithmetic.
|
||||
*/
|
||||
function backup(mark: number) {
|
||||
const cycle = dep.slice(mark)
|
||||
dep.length = mark
|
||||
|
||||
const paradox = cycle.some((p) => orderAt(p)?.type === 'convoy')
|
||||
|
||||
for (const p of cycle) {
|
||||
const o = orderAt(p)
|
||||
if (paradox) {
|
||||
if (o?.type === 'move' && isConvoyed(p)) {
|
||||
state.set(p, 'resolved')
|
||||
result.set(p, false)
|
||||
} else {
|
||||
state.set(p, 'unresolved')
|
||||
}
|
||||
} else {
|
||||
// Everybody in the ring moves.
|
||||
state.set(p, 'resolved')
|
||||
result.set(p, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ run
|
||||
|
||||
const success = new Map<string, boolean>()
|
||||
for (const p of orders.keys()) success.set(p, resolve(p))
|
||||
|
||||
const dislodged = new Map<string, Dislodgement>()
|
||||
for (const p of board.keys()) {
|
||||
if (!isDislodged(p)) continue
|
||||
const attacker = movesInto(p).find((q) => success.get(q))!
|
||||
dislodged.set(p, { unit: unitAt(p)!, attackedFrom: attacker })
|
||||
}
|
||||
|
||||
/*
|
||||
* A province where two moves bounced is closed to retreats. It is the one
|
||||
* piece of this that the retreat phase needs and the movement phase is the
|
||||
* only thing that knows it.
|
||||
*/
|
||||
const bounced = new Set<string>()
|
||||
for (const [p] of orders) {
|
||||
const o = orderAt(p)
|
||||
if (o?.type !== 'move' || success.get(p)) continue
|
||||
const dest = base(o.to)
|
||||
if (!unitAt(dest) && movesInto(dest).filter((q) => hasPath(q)).length > 1) bounced.add(dest)
|
||||
}
|
||||
|
||||
return { success, dislodged, bounced }
|
||||
}
|
||||
Reference in New Issue
Block a user