From 00a2b8880b91587d2b4031a33890c19bf117fe3a Mon Sep 17 00:00:00 2001 From: Coffey Labs <51408202+LINUXexpert-org@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:00:00 -0700 Subject: [PATCH] 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. --- src/games/scan.test.ts | 64 +++++++++++++++++++++++++++++++++++++ src/games/scan.ts | 72 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 137 insertions(+) create mode 100644 src/games/scan.test.ts create mode 100644 src/games/scan.ts diff --git a/src/games/scan.test.ts b/src/games/scan.test.ts new file mode 100644 index 0000000..cb8e02e --- /dev/null +++ b/src/games/scan.test.ts @@ -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) => { + 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', + ) + }) +}) diff --git a/src/games/scan.ts b/src/games/scan.ts new file mode 100644 index 0000000..51921a4 --- /dev/null +++ b/src/games/scan.ts @@ -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 + + 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, + }, + } + }, +}) diff --git a/src/index.ts b/src/index.ts index 2c0d578..655e827 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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)