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:
@@ -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 game’s 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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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.
|
// Registering is the import's side effect; the registry is the only index.
|
||||||
import './games/lemonade.ts'
|
import './games/lemonade.ts'
|
||||||
|
import './games/scan.ts'
|
||||||
import './games/wumpus.ts'
|
import './games/wumpus.ts'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 5184)
|
const PORT = Number(process.env.PORT ?? 5184)
|
||||||
|
|||||||
Reference in New Issue
Block a user