/** * The four game types (`src/web/presets.ts`) — Jesse's design, 2026-08-23. * * These numbers are a DESIGN, not an implementation detail: they say what Co-op asks of a table and * what Cutthroat refuses to. Pinned here so that changing one is a decision somebody makes on * purpose rather than something a refactor can do quietly. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { PRESETS, closestPreset, configFromPreset, configFromSettings, differencesFrom, gameTypeLabel, preset, presetOf, presetSettings, settingsOf, } from '../src/web/presets.ts'; import type { PresetName } from '../src/web/presets.ts'; const NAMES: PresetName[] = ['solitaire', 'coop', 'competitive', 'cutthroat']; describe('what each game type is', () => { it('deals six cards in every type — the hand limit is three, so the first turn is a discard', () => { for (const name of NAMES) { assert.equal(presetSettings(name, 4, 5).startingHand, 'sixRandom', `${name} does not deal six`); } }); it('scores Cutthroat as Competitive, and Co-op as itself', () => { assert.equal(preset('cutthroat').scoring, 'competitive'); assert.equal(preset('competitive').scoring, 'competitive'); assert.equal(preset('coop').scoring, 'coop'); assert.equal(preset('solitaire').scoring, 'solitaire'); }); it('lets the opponent-directed cards into Competitive and Cutthroat only', () => { // Co-op has no opponent to point them at; solitaire has nobody at all. Inert either way until // the cards are built (`setup.ts`), which is why no screen offers this as a control any more. assert.deepEqual( PRESETS.filter((p) => p.pvpCards).map((p) => p.name), ['competitive', 'cutthroat'], ); }); it('pays for a transit in Co-op alone — the one economy that pays everybody at once', () => { assert.equal(presetSettings('coop', 4, 5).trainPerTransit, 1); for (const name of ['solitaire', 'competitive', 'cutthroat'] as PresetName[]) { assert.equal(presetSettings(name, 4, 5).trainPerTransit, 0, `${name} pays for transits`); } }); it('lets an Extra be planted in another player’s district in Cutthroat only', () => { assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice'); assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice'); assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice'); // At one player the two rules ARE the same rule — `apply.ts` only rejects `ownOffice` when the // start is another seat's, which cannot happen. Solitaire said `anyOffice` until 2026-08-30: // true, and it read wrong, since a lone player has no "any player" to be contrasted with. The // label changed and the behaviour did not. assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'ownOffice'); }); it('leaves every optional rule off, in every type', () => { for (const name of NAMES) { const s = presetSettings(name, 4, 5); assert.deepEqual( [s.reducedVisibility, s.employeeRotation, s.emergencyToolbox], [false, false, false], `${name} switches an optional rule on`, ); } }); }); describe('the Revenue floor is a formula, not a number', () => { /** * Jesse gave these at five Days — Co-op 15 × players, Competitive 10 × players — and they scale, * because a ten-Day game with a five-Day target is not a target. The floor follows the table size * AND the length, which is exactly why both sit above the type radios as parameters rather than * below them as rules. */ it('asks 15 per player in Co-op at five Days, and 10 in Competitive', () => { for (const players of [2, 3, 4]) { assert.equal(presetSettings('coop', players, 5).minCombinedRevenue, 15 * players); assert.equal(presetSettings('competitive', players, 5).minCombinedRevenue, 10 * players); } }); it('scales both ways with the Day count', () => { assert.equal(presetSettings('coop', 3, 8).minCombinedRevenue, 3 * 3 * 8); assert.equal(presetSettings('coop', 3, 3).minCombinedRevenue, 3 * 3 * 3); assert.equal(presetSettings('competitive', 4, 10).minCombinedRevenue, 2 * 4 * 10); assert.equal(presetSettings('competitive', 2, 3).minCombinedRevenue, 2 * 2 * 3); }); it('asks nothing at all in Cutthroat, and leaves only the per-Day collision check standing', () => { const s = presetSettings('cutthroat', 4, 5); assert.equal(s.minCombinedRevenue, 0, 'Cutthroat has a Revenue floor'); assert.equal(s.maxCollisionsTotal, 0, 'Cutthroat caps collisions across the game'); assert.equal(s.maxCollisionsPerDay, 3, 'three collisions in one Day still ends a Cutthroat game'); }); }); describe('naming a game from its numbers', () => { it('reads every type back as itself, at every table size and length', () => { for (const name of NAMES) { const players = name === 'solitaire' ? 1 : 3; for (const days of [3, 5, 10]) { const config = configFromPreset(name, players, days); assert.equal(presetOf(config, players, days), name, `${name} at ${days} Days did not read back`); } } }); it('tells Cutthroat and Competitive apart, though they are scored the same way', () => { const cut = configFromPreset('cutthroat', 4, 5); const comp = configFromPreset('competitive', 4, 5); assert.equal(cut.mode, comp.mode, 'these two are meant to share a scoring mode'); assert.equal(presetOf(cut, 4, 5), 'cutthroat'); assert.equal(presetOf(comp, 4, 5), 'competitive'); }); it('calls a changed rule Custom, and says which rule', () => { const base = configFromPreset('coop', 4, 5); const settings = { ...settingsOf(base), emergencyToolbox: true }; const custom = configFromSettings(settings, 'coop', 5, false); assert.equal(presetOf(custom, 4, 5), 'custom'); assert.deepEqual(differencesFrom('coop', settings, 4, 5), ['emergencyToolbox']); }); it('does not call a Co-op game Competitive just because its dials line up', () => { // The scoring mode is part of the comparison: the same numbers under a different mode are a // different game, and the type is what a player reads to know which. const settings = presetSettings('competitive', 4, 5); const coopScored = configFromSettings(settings, 'coop', 5, false); assert.notEqual(presetOf(coopScored, 4, 5), 'competitive'); assert.equal(presetOf(coopScored, 4, 5), 'custom'); }); it('measures a Custom game against the nearest type it is scored as', () => { // A join preview is handed a finished config and nothing else — "Custom" alone would tell a // player nothing about what they are sitting down to. const settings = { ...presetSettings('cutthroat', 4, 5), freightPerLoad: 3 }; const config = configFromSettings(settings, 'competitive', 5, true); const near = closestPreset(config, 4, 5); assert.equal(near.name, 'cutthroat', 'a tuned Cutthroat game was measured against something else'); assert.deepEqual(near.differing, ['freightPerLoad']); }); it('says what a Custom game is scored as, since the dials cannot', () => { assert.match(gameTypeLabel('custom', 'coop'), /Co-op/); assert.match(gameTypeLabel('custom', 'competitive'), /Competitive/); assert.equal(gameTypeLabel('cutthroat', 'competitive'), 'Cutthroat'); }); }); describe('the config a form produces', () => { it('carries the type’s scoring mode and its stance on the opponent cards', () => { for (const name of NAMES) { const config = configFromPreset(name, name === 'solitaire' ? 1 : 4, 5); assert.equal(config.mode, preset(name).scoring); assert.equal(config.pvpCardsAllowed, preset(name).pvpCards); } }); it('spells out every house rule rather than leaving one to a default somewhere else', () => { const config = configFromPreset('competitive', 3, 5); assert.ok(config.houseRules?.startingHand, 'the opening hand was left unnamed'); assert.ok(config.houseRules?.extraStart, 'the Extra rule was left unnamed'); assert.deepEqual(config.houseRules?.revenue, { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0, }); }); });