Compare commits

..
1 Commits
Author SHA1 Message Date
Jesse.Markowitz fb042382bc v0.4.9f — an Extra starts where you put it, a train card is never discarded
The 0.6.2 change, ported to the playtest line. Three from the v0.4.9e gameplay-testing round —
Gitea#4 extras did not start where the player said, #6 train cards could be discarded, #7 four
train cards had the wrong coach counts — plus two bugs found underneath #4. Gitea#2 is diagnosed
but NOT fixed; it needs a ruling, and the reasoning is in TODO.md.

GITEA#4 — AN EXTRA STARTS WHERE THE PLAYER PUTS IT. Both Division Points are offered now, and the
START decides the direction rather than the number: an odd westbound Extra placed at the WEST end
would otherwise leave the Division on its first move having crossed nothing and be paid for the
run. At an Interchange or a Control Point the player picks the direction. The Interchange start is
a YARD, off the running line, which is what makes the Superintendent clause work — placing an
Extra there can never force a collision, a guaranteed one holds it for another Stage, and a
potential one is the Superintendent's to rule on, which are §8.1's two existing answers. Where an
Extra may start is a new setting in the New Game dialog, defaulting to what the engine already did
so this build does not change under its testers mid-line.

FOUND UNDERNEATH IT, both already shipping: an Extra started away from a Division Point could
never be given a consist and ran empty, so the Control Point start has been broken since it was
added; and collide left destroyed trains' transits on the Mainline card, permanently poisoning
that card for every later train.

THE MAINLINE CARDS ARE DEALT FROM THE PRINTED DECK NOW, without replacement. They were drawn from
the nine types with replacement, so a Division could hold two Interchanges — and "an Extra may
start at the Interchange if one is on the board" only reads as a rule if the board holds at most
one. This re-deals every seed, so both published replays were retired and re-recorded.

GITEA#6 — A TRAIN CARD IS NEVER DISCARDED, Timetabled and Extra alike. No forcing mechanism was
needed: nothing discardable plus a hand over the limit leaves exactly one legal way to end the
turn, and playing a train is unconditionally legal, so a hand of four cannot trap anyone. The hand
panel says so on the train, and the blocked end-turn button changes its wording.

GITEA#7 — COACH COUNTS. 1/2 Crack Limited 3 -> 2, 5/6 The Sparrow 2 -> 3.

WHAT DIFFERS FROM THE MAIN LINE. The car-placement round rotation test that accompanies #7 there
arrived with the 0.6 multiplayer work and does not exist here, so there was nothing for the
coach-count change to follow. src/web/main.ts and test/web.test.ts needed hand-resolution: this
line's New Game dialog has neither the mode radios nor the victory dials, so only the Extra-start
parts were taken. The content.ts comment pass came across whole; main's TODO documentation item
did not, since this line keeps its own TODO.

623 tests pass, tsc clean, site builds.
2026-08-22 21:06:54 -04:00
2 changed files with 32 additions and 113 deletions
-20
View File
@@ -78,26 +78,6 @@ end-turn button changes its wording when every card held is a train.
**Both published replays were retired by the rules change and re-recorded.** 623 tests pass. **Both published replays were retired by the rules change and re-recorded.** 623 tests pass.
### The deploy script was broken by a host upgrade
Not part of the playtest report — found trying to publish this build. Every deploy died with
`login failed: 404 404 page not found`, because the File Browser host has been upgraded to
**FileBrowser Quantum**, a fork whose API differs from the v2.63 one `deploy-web.ts` was written
against. Three things moved at once, each fatal on its own:
1. **Auth is a session COOKIE**, not a JWT in the response body sent back as `X-Auth:`.
2. **The password is a header** — `X-Password`, URL-encoded — not a JSON body field.
3. **The path is a query parameter** (`?path=`), and every resource call must also name a
**`source`**: Quantum can serve several named stores and refuses any call that does not say which
("no source provided"). The v2.63 API had no such concept.
Rewritten against the running instance's own bundle rather than guessed — the same discipline the
v2.63 version was written with — and each path confirmed against the live host by response code: an
endpoint that exists answers a bad password with **401**, one that does not answers **404**. The
source is discovered from `GET /api/settings/sources`; one configured source is used silently, and
several makes the script stop and list them rather than deploy the site into the wrong store.
`FB_SOURCE` overrides it, and `FB_OTP` carries a two-factor code.
--- ---
## 0.4.9e — 2026-08-22 ## 0.4.9e — 2026-08-22
+32 -93
View File
@@ -1,29 +1,11 @@
/** /**
* Build the solitaire site and push it to a FileBrowser instance. * Build the solitaire site and push it to a File Browser instance.
* *
* REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63 * Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
* to the Quantum fork, whose API is different in three ways at once, and every deploy failed with
* `login failed: 404 404 page not found` — the old `/api/login` simply is not there any more.
* *
* Read from the running instance's own bundle rather than guessed, the same way the v2.63 version * POST /api/login {username, password, recaptcha} -> JWT as plain text
* was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and * POST /api/resources/<dir>/ X-Auth: <jwt> -> create a directory
* each path confirmed against the live host by the response code: an endpoint that exists answers a * POST /api/resources/<file>?override=true X-Auth: <jwt>, body = bytes -> upload
* bad password with **401**, one that does not answers **404**.
*
* POST /api/auth/login?username=<u>&recaptcha=
* headers X-Password: <urlencoded>, X-Secret: <otp or empty> -> sets a session COOKIE
* GET /api/settings/sources -> the named sources
* POST /api/resources?path=<p>&source=<s>&isDir=true -> create a directory
* POST /api/resources?path=<p>&source=<s>&override=true body=bytes -> upload
*
* THREE THINGS MOVED, and each would break on its own:
* 1. AUTH IS A COOKIE, not an `X-Auth: <jwt>` header. Login returns no usable token in its body;
* the session arrives in `Set-Cookie` and every later request has to carry it back.
* 2. THE PASSWORD IS A HEADER, `X-Password`, URL-encoded — not a JSON body field.
* 3. THE PATH IS A QUERY PARAMETER, `?path=`, not part of the URL, and every resource call also
* needs a **`source`** naming which configured store to write to. Quantum throws "no source
* provided" without it. `FB_SOURCE` names it; left unset, the sole configured source is used,
* and if there is more than one this stops and lists them rather than guessing.
* *
* File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site. * File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
* So the job here is simply to land the built files in the right folder, intact. * So the job here is simply to land the built files in the right folder, intact.
@@ -36,8 +18,6 @@
* Optional: * Optional:
* FB_URL default https://phoenix.local:58157 * FB_URL default https://phoenix.local:58157
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from * FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
* FB_SOURCE which configured source to write to; discovered automatically when there is one
* FB_OTP the one-time code, if the account has two-factor enabled
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host) * FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
* SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at * SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at
* --dry-run list what would be sent, contact nothing * --dry-run list what would be sent, contact nothing
@@ -64,8 +44,6 @@ const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replac
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`; const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
const USER = process.env['FB_USER'] ?? ''; const USER = process.env['FB_USER'] ?? '';
const PASS = process.env['FB_PASS'] ?? ''; const PASS = process.env['FB_PASS'] ?? '';
const OTP = process.env['FB_OTP'] ?? '';
const SOURCE_ENV = process.env['FB_SOURCE'] ?? '';
const DRY = process.argv.includes('--dry-run'); const DRY = process.argv.includes('--dry-run');
/** /**
@@ -97,80 +75,37 @@ const CONTENT_TYPES: Record<string, string> = {
'.txt': 'text/plain', '.txt': 'text/plain',
}; };
/**
* Log in and return the session cookie every later request must carry.
*
* The password goes in a HEADER and URL-encoded, which is Quantum's own client does
* (`X-Password: encodeURIComponent(password)`). The body carries nothing useful on success — the
* session is in `Set-Cookie`, so a deploy that ignored the cookie would authenticate and then be
* rejected by every upload.
*/
async function login(): Promise<string> { async function login(): Promise<string> {
const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`; const res = await fetch(`${URL_BASE}/api/login`, {
const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
}); });
const body = await res.text(); const body = await res.text();
if (!res.ok) { if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
// 401 here is a wrong username/password; 404 would mean this build has moved the API again. if (!body.trim()) throw new Error('login returned an empty token');
throw new Error(`login failed: ${res.status} ${body || res.statusText}`); return body.trim();
}
const cookies = res.headers.getSetCookie();
if (cookies.length === 0) throw new Error('login succeeded but set no session cookie');
return cookies.map((c) => c.split(';')[0]).join('; ');
} }
/** async function makeDir(jwt: string, path: string): Promise<void> {
* WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call // Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
* that does not name one ("no source provided"), which is the parameter the v2.63 API had no // the normal case on every deploy after the first.
* concept of. One configured source is the normal case and is used without asking; more than one is const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
* ambiguous, and guessing would silently deploy the site into the wrong store.
*/
async function resolveSource(cookie: string): Promise<string> {
if (SOURCE_ENV) return SOURCE_ENV;
const res = await fetch(`${URL_BASE}/api/settings/sources`, { headers: { cookie } });
if (!res.ok) throw new Error(`could not list sources: ${res.status} ${await res.text()}`);
const names = Object.keys((await res.json()) ?? {});
if (names.length === 1) return names[0]!;
if (names.length === 0) throw new Error('the server reports no sources at all');
throw new Error(`several sources configured (${names.join(', ')}) — pick one with FB_SOURCE=<name>`);
}
function resourceUrl(source: string, path: string, extra: Record<string, string>): string {
const params = new URLSearchParams({ path, source, ...extra });
return `${URL_BASE}/api/resources?${params}`;
}
async function makeDir(cookie: string, source: string, path: string): Promise<void> {
const res = await fetch(resourceUrl(source, path, { isDir: 'true' }), {
method: 'POST', method: 'POST',
headers: { cookie }, headers: { 'X-Auth': jwt },
}); });
if (res.ok) return; if (!res.ok && res.status !== 409) {
/** throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
* "Already there" is the normal case on every deploy after the first, and Quantum is not }
* consistent about which code it reports it with. So the two failures worth stopping for are
* named — a rejected session, and a server that broke — and every other 4xx is treated as the
* directory already existing. A directory that genuinely is not there fails loudly at the upload
* a moment later, which is a better place to find out than a guess here.
*/
const fatal = res.status === 401 || res.status === 403 || res.status >= 500;
if (fatal) throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
} }
async function upload( async function upload(jwt: string, localPath: string, remotePath: string): Promise<void> {
cookie: string,
source: string,
localPath: string,
remotePath: string,
): Promise<void> {
const bytes = readFileSync(localPath); const bytes = readFileSync(localPath);
const ext = remotePath.slice(remotePath.lastIndexOf('.')); const ext = remotePath.slice(remotePath.lastIndexOf('.'));
const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), { const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
method: 'POST', method: 'POST',
headers: { headers: {
cookie, 'X-Auth': jwt,
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
'Content-Length': String(bytes.byteLength), 'Content-Length': String(bytes.byteLength),
}, },
@@ -179,6 +114,11 @@ async function upload(
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
} }
/** Encode each segment but keep the separators, so a path stays a path. */
function encodePath(p: string): string {
return p.split('/').map(encodeURIComponent).join('/');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
console.log('building…'); console.log('building…');
@@ -207,16 +147,15 @@ if (DRY) {
); );
} }
const cookie = await login(); const jwt = await login();
const source = await resolveSource(cookie); console.log('logged in');
console.log(`logged in — writing to source "${source}"`);
await makeDir(cookie, source, DEST); await makeDir(jwt, DEST);
for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`); for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
let done = 0; let done = 0;
for (const f of files) { for (const f of files) {
await upload(cookie, source, join(dist, f), `${DEST}/${f}`); await upload(jwt, join(dist, f), `${DEST}/${f}`);
done++; done++;
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`); console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
} }