Add the Long Range Scan board (#2)

One file and an import, which is what adding a game is meant to cost.

Ranked on raiders destroyed, then days left on the orders, then torpedoes
still in the racks. Days are whole here and shown to a tenth in the game: the
ranked columns are integers, and a tie-break finer than a day would be
measuring the dice rather than the captain.

The cross-field rules are the ones that matter -- a patrol that ended because
time ran out cannot have time left on it, and nobody clears a galaxy without
destroying anything, since a scattered galaxy always has at least one raider
in it.
This commit is contained in:
Coffey Labs
2026-09-09 00:00:00 -07:00
committed by GitHub
parent 52f794e698
commit 00a2b8880b
3 changed files with 137 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { scan } from './scan.ts'
/**
* The cross-field rules are the ones worth a test: a single column out of
* range is a one-line check that reads correctly, and two columns that
* contradict each other is where a board with no accounts actually earns its
* keep.
*/
const good = { name: 'ADA', killed: 4, daysLeft: 9, torpedoes: 3, ending: 'resigned' }
const why = (over: Record<string, unknown>) => {
const r = scan.validate({ ...good, ...over })
return r.ok ? null : r.why
}
describe('a plausible patrol', () => {
it('is taken, and comes back in the games own field names', () => {
const r = scan.validate(good)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.entry).toEqual({
name: 'ADA',
killed: 4,
daysLeft: 9,
torpedoes: 3,
ending: 'resigned',
})
}
})
it('may have ended with nothing left, or with everything left', () => {
expect(why({ killed: 0, daysLeft: 0, torpedoes: 0, ending: 'destroyed' })).toBeNull()
expect(why({ killed: 0, daysLeft: 35, torpedoes: 10, ending: 'resigned' })).toBeNull()
})
})
describe('the impossible', () => {
it('refuses days and torpedoes nobody was issued', () => {
expect(why({ daysLeft: 36 })).toBe('days out of range')
expect(why({ torpedoes: 11 })).toBe('torpedoes out of range')
expect(why({ killed: 193 })).toBe('killed out of range')
})
it('refuses a fraction of a day, because the columns are integers', () => {
expect(why({ daysLeft: 9.4 })).toBe('days out of range')
})
it('refuses an ending nobody could have had', () => {
expect(why({ ending: 'ascended' })).toBe('unknown ending')
})
it('refuses running out of time with time on the clock', () => {
expect(why({ ending: 'out-of-time', daysLeft: 4 })).toBe('out of time with time left')
expect(why({ ending: 'out-of-time', daysLeft: 0 })).toBeNull()
})
it('refuses clearing a galaxy that never had anything in it', () => {
expect(why({ ending: 'mission-complete', killed: 0 })).toBe(
'cleared a galaxy without destroying anything',
)
})
})
+72
View File
@@ -0,0 +1,72 @@
import { cleanName, isInt, register } from '../registry.ts'
/**
* Long Range Scan.
*
* Bounded on every side, which makes this the easiest of the three to check.
* A galaxy is eight by eight quadrants holding at most three raiders each, a
* patrol is issued twenty-five to thirty-five days, and nobody has ever
* carried an eleventh torpedo. The clock is the interesting one: a patrol
* that ended because time ran out cannot have time left on it, and that is a
* claim about two fields at once rather than about either.
*
* Days are whole here and shown to a tenth in the game. The board's ranked
* columns are integers -- see `store.ts`, where that is the whole point of
* storing them positionally -- and a tie-break finer than a day would be
* measuring the dice rather than the captain.
*/
const START_TORPEDOES = 10
/** Twenty-five days, plus up to ten. */
const MAX_DAYS = 35
/** Three to a quadrant, sixty-four quadrants, and that is the ceiling. */
const MAX_RAIDERS = 192
const ENDINGS = new Set([
'mission-complete',
'out-of-time',
'destroyed',
'stranded',
'resigned',
'base-destroyed',
])
export const scan = register({
id: 'scan',
// Most destroyed, then the days they did not need, then the torpedoes.
metrics: [
{ key: 'killed', dir: 'desc' },
{ key: 'daysLeft', dir: 'desc' },
{ key: 'torpedoes', dir: 'desc' },
],
extras: ['ending'],
validate(raw) {
if (typeof raw !== 'object' || raw === null) return { ok: false, why: 'not an object' }
const e = raw as Record<string, unknown>
if (!isInt(e.killed, 0, MAX_RAIDERS)) return { ok: false, why: 'killed out of range' }
if (!isInt(e.daysLeft, 0, MAX_DAYS)) return { ok: false, why: 'days out of range' }
if (!isInt(e.torpedoes, 0, START_TORPEDOES))
return { ok: false, why: 'torpedoes out of range' }
if (e.ending !== null && (typeof e.ending !== 'string' || !ENDINGS.has(e.ending)))
return { ok: false, why: 'unknown ending' }
// The fields look independent and are not.
if (e.ending === 'out-of-time' && e.daysLeft > 0)
return { ok: false, why: 'out of time with time left' }
if (e.ending === 'mission-complete' && e.killed === 0)
return { ok: false, why: 'cleared a galaxy without destroying anything' }
return {
ok: true,
entry: {
name: cleanName(e.name),
killed: e.killed,
daysLeft: e.daysLeft,
torpedoes: e.torpedoes,
ending: (e.ending as string | null) ?? null,
},
}
},
})
+1
View File
@@ -8,6 +8,7 @@ import { BOARD_LIMIT, openStore, type Row } from './store.ts'
// Registering is the import's side effect; the registry is the only index.
import './games/lemonade.ts'
import './games/scan.ts'
import './games/wumpus.ts'
const PORT = Number(process.env.PORT ?? 5184)