Two setup screens, not three — the in-game dialog is deleted

Jesse: "it should not go to a separate screen. We should reuse the
Solitaire New Game Screen… in general we should reuse what we already
have."

#newgamedlg was a third copy of the same questions and the one that
drifted: shown only to a solitaire player, it asked "Everyone loses if
COMBINED Revenue…" and explained Employee Rotation in full multiplayer
terms beside a control it had disabled. Both were on the list to
re-word; deleting the screen removes the drift instead of restating it.

New game opens the setup screen IN PLACE rather than navigating, so the
live session stays in memory: the fields open on the rules actually
being played (what the dialog was good for), and Continue Existing Saved
Game puts the board back with no reload. render() calls save() every
frame, so nothing is at risk either way.

The two remaining screens now match below their headers — same three
parameters in the same order, same seed note, same chair note. Solitaire
shows Players at the table locked at 1 rather than omitting it: a fixed
control says "same form, table of one", a missing one made it a
different form sharing a rules block.

Drift guard drops to two prefixes and now fails if any ng- id returns.

Stays in the unshipped v0.7.9. 874 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
Jesse.Markowitz
2026-08-30 07:58:56 -04:00
co-authored by Claude Sonnet 5
parent cf018b4a5f
commit 131538dc7c
4 changed files with 173 additions and 316 deletions
+68 -71
View File
@@ -3739,9 +3739,9 @@ describe('the lobby screen', () => {
'lb-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'lb-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'lb-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
};
const matching = (sel: string): Radio[] => {
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
@@ -3994,7 +3994,7 @@ describe('the lobby screen', () => {
});
});
describe('the lobby and the dialog ask the same questions', () => {
describe('the lobby and the setup screen ask the same questions', () => {
/**
* THE DRIFT GUARD.
*
@@ -4009,20 +4009,20 @@ describe('the lobby and the dialog ask the same questions', () => {
return readFileSync(join(dist, 'play.html'), 'utf8');
};
it('carries every field of the shared block on all three screens', () => {
it('carries every field of the shared block on both screens', () => {
// `ss-` joined `lb-`/`ng-` 2026-08-29: the pre-game solitaire setup screen drives the identical
// block ("asking first is the only path"). Same drift guard, one more prefix.
const html = page();
for (const prefix of ['lb-', 'ng-', 'ss-']) {
for (const prefix of ['lb-', 'ss-']) {
for (const selector of fieldSelectors(prefix)) {
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
}
}
});
it('offers all five game types on all three screens', () => {
it('offers all five game types on both screens', () => {
const html = page();
for (const prefix of ['lb-', 'ng-', 'ss-']) {
for (const prefix of ['lb-', 'ss-']) {
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
assert.ok(
html.includes(`name="${prefix}type" value="${type}"`),
@@ -4049,13 +4049,17 @@ describe('the lobby and the dialog ask the same questions', () => {
// The opponent-directed cards are unbuilt, and `buildDeck` holds them out however the config is
// set — so the checkbox could not do anything, on either screen. The fact is stated in words.
const html = page();
assert.ok(!html.includes('id="ng-pvp"'), 'the dialog still has the dead PvP checkbox');
assert.ok(!html.includes('id="lb-pvp"'), 'the lobby still has the dead PvP checkbox');
assert.ok(!html.includes('id="ss-pvp"'), 'the setup screen still has the dead PvP checkbox');
// The in-game dialog was a THIRD copy of this block and the one that drifted — it kept the
// multiplayer wording on a solitaire-only screen. Deleted 2026-08-30; nothing may reintroduce
// a prefix that no screen owns.
assert.ok(!/id="ng-/.test(html), 'the deleted in-game dialog has come back');
assert.match(html, /opponent-directed cards[\s\S]{0,120}not implemented yet/i);
});
});
describe('the New Game dialog', () => {
describe('the solitaire setup screen', () => {
/**
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
@@ -4097,14 +4101,10 @@ describe('the New Game dialog', () => {
}));
// ONE set per page, not one per element: the block is addressed through the document, and a stub
// that handed each element its own copy would let a broken selector still pass.
// ONE set, matching the markup's own `checked` defaults. There were two until 2026-08-30, one
// per screen, because the in-game dialog was a second copy of this block — it is deleted, and
// the setup screen it folded into is the only thing on the page driving these radios now.
const groups: Record<string, Radio[]> = {
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
// The pre-game setup screen (Gitea, "asking first is the only path", 2026-08-29) drives the
// same shared block under the `ss-` prefix — one set here too, matching the markup's own
// `checked` defaults rather than the dialog's (Solitaire, not Co-op: there is no live game to
// reopen on, so the static default IS the Solitaire default).
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
@@ -4176,15 +4176,15 @@ describe('the New Game dialog', () => {
/** What every field of the block reads, so a test can assert the whole form at once. */
const readForm = (els: Map<string, Record<string, unknown>>, groups: Record<string, { value: string; checked: boolean }[]>) => ({
hand: groups['ng-hand']!.find((r) => r.checked)?.value,
extra: groups['ng-extra']!.find((r) => r.checked)?.value,
type: groups['ng-type']!.find((r) => r.checked)?.value,
passenger: els.get('ng-passenger')!['value'],
freight: els.get('ng-freight')!['value'],
transit: els.get('ng-transit')!['value'],
days: els.get('ng-days')!['value'],
minrev: els.get('ng-minrev')!['value'],
tossloco: els.get('ng-tossloco')!['checked'],
hand: groups['ss-hand']!.find((r) => r.checked)?.value,
extra: groups['ss-extra']!.find((r) => r.checked)?.value,
type: groups['ss-type']!.find((r) => r.checked)?.value,
passenger: els.get('ss-passenger')!['value'],
freight: els.get('ss-freight')!['value'],
transit: els.get('ss-transit')!['value'],
days: els.get('ss-days')!['value'],
minrev: els.get('ss-minrev')!['value'],
tossloco: els.get('ss-tossloco')!['checked'],
});
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
@@ -4193,9 +4193,9 @@ describe('the New Game dialog', () => {
const { els, groups } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the New game button did not open the setup screen');
assert.equal(els.get('gameui')!['hidden'], true, 'the board is still showing over the setup screen');
assert.equal(els.get('ss-seed')!['value'], '', 'the seed box kept the last game’s seed');
const form = readForm(els, groups);
assert.equal(form.passenger, '2');
assert.equal(form.freight, '3');
@@ -4230,7 +4230,7 @@ describe('the New Game dialog', () => {
(tuned.els.get('newgame')!['onclick'] as () => void)();
assert.equal(readForm(tuned.els, tuned.groups).type, 'custom', 'a game paying for transits still read as Solitaire');
assert.match(
String(tuned.els.get('ng-type-note')!['textContent']),
String(tuned.els.get('ss-type-note')!['textContent']),
/1 setting differs from Solitaire/,
'the note did not say what differs',
);
@@ -4239,29 +4239,30 @@ describe('the New Game dialog', () => {
it('offers the multiplayer types, disabled — one list across both screens, dealt from one of them', async () => {
const { els, groups } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const disabled = groups['ng-type']!
const disabled = groups['ss-type']!
.filter((r) => (r as { disabled: boolean }).disabled)
.map((r) => r.value);
assert.deepEqual(disabled, ['coop', 'competitive', 'cutthroat'], 'the wrong game types are dealable here');
// Deal is never disabled here any more: the only types this screen can SELECT are the two it can
// deal, so a disabled button would be answering a question the radios no longer ask.
assert.ok(
readFileSync(join(dist, 'play.html'), 'utf8').includes('id="ng-multiplayer-note"'),
'nothing on the dialog says where a multiplayer game comes from',
assert.match(
readFileSync(join(dist, 'play.html'), 'utf8'),
/use the Multiplayer door instead/,
'nothing on the setup screen says where a multiplayer game comes from',
);
});
it('clicking a game type resets every rule below to it, and leaves the parameters alone', async () => {
const { els, groups } = await load('?seed=430&transit=4');
(els.get('newgame')!['onclick'] as () => void)();
els.get('ng-days')!['value'] = '8';
(els.get('ng-days')!['oninput'] as () => void)();
els.get('ss-days')!['value'] = '8';
(els.get('ss-days')!['oninput'] as () => void)();
// 3 × 1 player × 8 days: the floor follows the length, and changing the length is not a rule
// change, so this is still Solitaire rather than Custom.
assert.equal(readForm(els, groups).minrev, '24', 'the Revenue floor did not follow the Day count');
const solitaire = groups['ng-type']!.find((r) => r.value === 'solitaire')!;
for (const r of groups['ng-type']!) r.checked = r === solitaire;
const solitaire = groups['ss-type']!.find((r) => r.value === 'solitaire')!;
for (const r of groups['ss-type']!) r.checked = r === solitaire;
(solitaire as { onchange: (() => void) | null }).onchange!();
const form = readForm(els, groups);
@@ -4274,15 +4275,13 @@ describe('the New Game dialog', () => {
const { els, groups, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = '99';
for (const r of groups['ng-hand']!) r.checked = r.value === 'threeTrackThreeOther';
els.get('ng-passenger')!['value'] = '5';
els.get('ng-freight')!['value'] = '0';
els.get('ng-transit')!['value'] = '2';
els.get('ng-toolbox')!['checked'] = true;
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
els.get('ss-seed')!['value'] = '99';
for (const r of groups['ss-hand']!) r.checked = r.value === 'threeTrackThreeOther';
els.get('ss-passenger')!['value'] = '5';
els.get('ss-freight')!['value'] = '0';
els.get('ss-transit')!['value'] = '2';
els.get('ss-toolbox')!['checked'] = true;
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(
n.search,
@@ -4294,26 +4293,28 @@ describe('the New Game dialog', () => {
it('a switched-off victory condition deals as 0, which is what the engine calls off', async () => {
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-coltotal-on')!['checked'] = false;
(els.get('ng-coltotal-on')!['onchange'] as () => void)();
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
els.get('ss-coltotal-on')!['checked'] = false;
(els.get('ss-coltotal-on')!['onchange'] as () => void)();
(els.get('ss-deal')!['onclick'] as () => void)();
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
});
it('deals nothing on cancel, and nothing on Esc', async () => {
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
// to be the test rather than "cancel" — the two arrive identically.
for (const returnValue of ['cancel', '']) {
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = returnValue;
(dlg['close'] as () => void)();
assert.equal(n.search, '?seed=430', `closing with "${returnValue}" navigated`);
assert.equal(n.reloads, 0, `closing with "${returnValue}" reloaded`);
}
it('backing out deals nothing and puts the same game back on screen', async () => {
/**
* WHAT CANCEL USED TO BE. The dialog had a Cancel button and an Esc key, and this pinned that
* neither dealt. The screen that replaced it has neither — it has "Continue Existing Saved
* Game", which has to do the same job and one more besides: the game was never navigated away
* from, so going back is showing the board again rather than reloading and replaying it.
*/
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
assert.equal(els.get('ss-resume')!['hidden'], false, 'mid-game there is no way back to the game');
(els.get('ss-resume')!['onclick'] as () => void)();
assert.equal(els.get('gameui')!['hidden'], false, 'backing out did not return to the board');
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen stayed up');
assert.equal(n.search, '?seed=430', 'backing out navigated');
assert.equal(n.reloads, 0, 'backing out reloaded, losing the game in memory');
});
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
@@ -4323,9 +4324,7 @@ describe('the New Game dialog', () => {
'?hand=sixRandom&extra=ownOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
const { els, nav: n } = await load(url);
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(n.search, url, 'the URL should be unchanged — that is the whole case');
assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all');
@@ -4451,10 +4450,8 @@ describe('the New Game dialog', () => {
// optional box is not worth writing.
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = 'not a number';
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
els.get('ss-seed')!['value'] = 'not a number';
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(
n.search,