Files
Jesse.MarkowitzandClaude Sonnet 5 f2c87b6871 v0.7.9 — solitaire setup screen feedback, and the dead settings it exposed
Four pieces of feedback from Jesse on the solitaire setup screen.

THE COLLISION LIMITS DID NOTHING IN SOLITAIRE. Asked to reword those
entries to "the game ends immediately and results in a loss", which was
unwriteable: advance.ts gated the §3.4 check on competitive/coop, and a
solitaire game's mode is 'solitaire'. Both limits were offered as live
settings, rode into the config, and never fired — the existing text was
already false. The exclusion was never a stated rule and nothing
recorded a reason for it. Jesse's ruling: the settings do what they say,
so the gate is gone rather than the controls.

Measured, not asserted — 200 standard developer-bot games:
loss/collisionFloor 1 in 200, Days played 5.00 -> 4.98 mean with a
minimum of 1, collisions per game unchanged at 0.14. Recorded in
TODO.md under Play Balance, since full-length figures predate it.

Extra start defaults to ownOffice: at one seat it is the same rule as
anyOffice (apply.ts only rejects another seat's start), so this is a
label fix with no gameplay effect.

Also: collision wording on all three screens, Employee Rotation reads
"not applicable for solitaire", and the save warning is legible at 14px
on an amber panel with buttons that say Continue Existing Saved Game and
Deal New Game.

869 tests pass, two new; one asserted the opposite of the ruling and
says so where it was reversed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
2026-08-30 01:01:11 -04:00

183 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
});
});
});