v0.7.1 — a caboose is not a load, a Day that says it ended, and a train you may throw away
Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for every item, and what was verified how: CHANGELOG.md. - Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded, white is empty" doubles as a piece count in the supply table; one read of the flag took that literally. A caboose carries the crew, not freight, so it is never a load. - Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click and the next — and both transient signals fade before a player reading the board notices. A modal stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game ends. - Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up to a Department slot, where a rival may pick it up — the second half of the ruling needed no code, since that is where every discard already goes. An Extra still may not. A New Game setting on this line (discardTimetabled, on by default), the plain rule on the 0.4.9 line. - Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A blocked platform now gives its reason, from the engine's own predicate, including how many coaches are stranded in Classification and what brings them back. The same four ship as v0.4.9g on the playtest line. Closes #2 Closes #8 Closes #9 Closes #10 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
This commit is contained in:
co-authored by
Claude Opus 5
parent
603d38602c
commit
441447648d
+98
-2
@@ -22,10 +22,11 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { dayEndHtml, facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import type { Frame } from '../src/sim/view.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
import {
|
||||
@@ -3090,6 +3091,84 @@ describe('the three places a game is drawn stay in step', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
// "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
|
||||
// time." A Day turns inside the phases that run themselves, so it passes between one click and
|
||||
// the next — the phase banner and the announcement flash are both gone in a few seconds.
|
||||
const frameAt = (day: number, days = 5): Frame => {
|
||||
const s = createEngineGame({
|
||||
id: 'dayend', seed: 4021,
|
||||
config: {
|
||||
mode: 'solitaire', days, minCombinedRevenue: 12, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
s.clock.day = day;
|
||||
return snapshot(s, [], null);
|
||||
};
|
||||
|
||||
it('names the Day that ENDED, not the one starting', () => {
|
||||
// Written from the frame after the rollover, so an off-by-one here would congratulate a player
|
||||
// on finishing a Day they have not played yet.
|
||||
const html = dayEndHtml(frameAt(3));
|
||||
assert.ok(html.includes('Day 2 has ended'), `wrong Day named:\n${html}`);
|
||||
assert.ok(html.includes('Day 3 of 5'), 'the Day now beginning is not named');
|
||||
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||
});
|
||||
|
||||
it('counts the last Day as the last Day rather than promising more', () => {
|
||||
const html = dayEndHtml(frameAt(6));
|
||||
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
|
||||
assert.ok(html.includes('last Day on the timetable'), `still offering Days to run:\n${html}`);
|
||||
assert.ok(!html.includes('Days left'), 'promises more Days after the last one');
|
||||
});
|
||||
|
||||
it('scores the table against the COMBINED target, not one player against it', () => {
|
||||
// `minCombinedRevenue` is a floor for the whole table. Showing one player's Revenue against a
|
||||
// four-player target reads as hopeless when the table may be well ahead.
|
||||
const f = frameAt(2);
|
||||
f.players = [
|
||||
{ index: 0, seat: 0, name: 'Ada', revenue: 4, hand: 3 },
|
||||
{ index: 1, seat: 1, name: 'Bo', revenue: 7, hand: 2 },
|
||||
];
|
||||
f.viewer = 0;
|
||||
const html = dayEndHtml(f);
|
||||
assert.ok(html.includes('<b>11</b>'), `combined Revenue is not 4 + 7:\n${html}`);
|
||||
assert.ok(html.includes('<b>12</b>'), 'the target is not shown');
|
||||
// Revenue order, so the leader is first: Bo (7) above Ada (4).
|
||||
assert.ok(html.indexOf('Bo') < html.indexOf('Ada'), 'the standings are not in Revenue order');
|
||||
assert.ok(html.includes('(you)'), 'the viewer is not marked in the standings');
|
||||
});
|
||||
|
||||
it('says nothing about collisions in a game that is not scored on them', () => {
|
||||
// §3.4's collision checks run in competitive and co-op ONLY, and `0` on a dial turns that check
|
||||
// off besides. A solitaire game carries the default dials and enforces neither, so a collision
|
||||
// budget on screen there would be a rule this game does not have.
|
||||
const solo = frameAt(2);
|
||||
assert.ok(!dayEndHtml(solo).includes('Collision'), 'reports a collision budget in solitaire');
|
||||
|
||||
const coop = frameAt(2);
|
||||
coop.mode = 'coop';
|
||||
coop.maxCollisionsTotal = 3;
|
||||
coop.collisionsTotal = 1;
|
||||
assert.ok(dayEndHtml(coop).includes('Collision'), 'hides collisions in a game scored on them');
|
||||
|
||||
const noDials = frameAt(2);
|
||||
noDials.mode = 'coop';
|
||||
noDials.maxCollisionsTotal = 0;
|
||||
noDials.maxCollisionsPerDay = 0;
|
||||
assert.ok(!dayEndHtml(noDials).includes('Collision'), 'reports collisions with both dials off');
|
||||
});
|
||||
|
||||
it('gives the page the dialog to fill', () => {
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
for (const id of ['dayenddlg', 'dayendbody']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||
it('does not gitignore a source page', () => {
|
||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||
@@ -3569,7 +3648,7 @@ describe('the New Game dialog', () => {
|
||||
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
|
||||
* settings, so what this really pins is that all of them ride in the URL and come back out.
|
||||
*
|
||||
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same eleven
|
||||
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same twelve
|
||||
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
||||
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
||||
*/
|
||||
@@ -3681,6 +3760,7 @@ describe('the New Game dialog', () => {
|
||||
transit: els.get('ng-transit')!['value'],
|
||||
days: els.get('ng-days')!['value'],
|
||||
minrev: els.get('ng-minrev')!['value'],
|
||||
tossloco: els.get('ng-tossloco')!['checked'],
|
||||
});
|
||||
|
||||
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
||||
@@ -3699,6 +3779,22 @@ describe('the New Game dialog', () => {
|
||||
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
|
||||
});
|
||||
|
||||
it('carries §6.2 in the URL, written only when it is OFF (Gitea#9)', async () => {
|
||||
// The setting defaults ON, so a link that spelled out `toss=1` every time would say nothing and
|
||||
// cost a parameter — the same reason the optional rules are written only when they are on. What
|
||||
// has to survive the trip is therefore the OFF case, which is the one that changes the game.
|
||||
const on = await load('?seed=430');
|
||||
(on.els.get('newgame')!['onclick'] as () => void)();
|
||||
assert.equal(readForm(on.els, on.groups).tossloco, true, 'a default game did not allow the discard');
|
||||
|
||||
const off = await load('?seed=430&toss=0');
|
||||
(off.els.get('newgame')!['onclick'] as () => void)();
|
||||
const form = readForm(off.els, off.groups);
|
||||
assert.equal(form.tossloco, false, '?toss=0 did not reach the dialog');
|
||||
// And it counts as a rule change, so the game is no longer the named type.
|
||||
assert.equal(form.type, 'custom', 'turning the rule off still read as Solitaire');
|
||||
});
|
||||
|
||||
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
|
||||
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
|
||||
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
|
||||
|
||||
Reference in New Issue
Block a user