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.
227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
/**
|
|
* Build the solitaire site and push it to a FileBrowser instance.
|
|
*
|
|
* REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63
|
|
* 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
|
|
* was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and
|
|
* each path confirmed against the live host by the response code: an endpoint that exists answers a
|
|
* 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.
|
|
* So the job here is simply to land the built files in the right folder, intact.
|
|
*
|
|
* CREDENTIALS COME FROM THE ENVIRONMENT and are never written anywhere. Putting a password in a
|
|
* repo is how it ends up in a commit, and this repo is going to be pushed.
|
|
*
|
|
* FB_USER=jesse FB_PASS='…' npm run deploy:web
|
|
*
|
|
* Optional:
|
|
* FB_URL default https://phoenix.local:58157
|
|
* 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)
|
|
* 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
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { dirname, join, posix, relative, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
/**
|
|
* Where Start9 Pages actually publishes the site — the address a player types, as opposed to the
|
|
* File Browser folder the files are uploaded INTO. The two are unrelated and the deploy output used
|
|
* to print only the second, which is the one nobody wants.
|
|
*
|
|
* Provisional: this is the playtesting host and it will change. Override with SITE_URL.
|
|
*/
|
|
const SITE_URL = process.env['SITE_URL'] ?? 'https://65.78.82.12:54697/';
|
|
const dist = join(root, 'dist');
|
|
|
|
const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, '');
|
|
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
|
|
const USER = process.env['FB_USER'] ?? '';
|
|
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');
|
|
|
|
/**
|
|
* A .local StartOS host presents a certificate the system store does not know. Disabling
|
|
* verification is opt-in and announced rather than silent: it is the right call on a LAN box you
|
|
* own and the wrong one everywhere else, and that judgment is not the script's to make quietly.
|
|
*/
|
|
if (process.env['FB_INSECURE'] === '1') {
|
|
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
|
|
console.warn('! TLS verification disabled (FB_INSECURE=1)');
|
|
}
|
|
|
|
/** Every file in `dir`, as paths relative to it, with POSIX separators. */
|
|
function walk(dir: string, base = dir): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) out.push(...walk(full, base));
|
|
else out.push(relative(base, full).split(sep).join('/'));
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
'.html': 'text/html',
|
|
'.js': 'text/javascript',
|
|
'.css': 'text/css',
|
|
'.json': 'application/json',
|
|
'.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> {
|
|
const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`;
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP },
|
|
});
|
|
const body = await res.text();
|
|
if (!res.ok) {
|
|
// 401 here is a wrong username/password; 404 would mean this build has moved the API again.
|
|
throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
|
}
|
|
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('; ');
|
|
}
|
|
|
|
/**
|
|
* WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call
|
|
* that does not name one ("no source provided"), which is the parameter the v2.63 API had no
|
|
* concept of. One configured source is the normal case and is used without asking; more than one is
|
|
* 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',
|
|
headers: { cookie },
|
|
});
|
|
if (res.ok) return;
|
|
/**
|
|
* "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(
|
|
cookie: string,
|
|
source: string,
|
|
localPath: string,
|
|
remotePath: string,
|
|
): Promise<void> {
|
|
const bytes = readFileSync(localPath);
|
|
const ext = remotePath.slice(remotePath.lastIndexOf('.'));
|
|
const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), {
|
|
method: 'POST',
|
|
headers: {
|
|
cookie,
|
|
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
|
|
'Content-Length': String(bytes.byteLength),
|
|
},
|
|
body: new Uint8Array(bytes),
|
|
});
|
|
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
console.log('building…');
|
|
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'inherit' });
|
|
|
|
const files = walk(dist);
|
|
if (files.length === 0) throw new Error('dist/ is empty — nothing to deploy');
|
|
|
|
const dirs = [...new Set(files.map((f) => posix.dirname(f)).filter((d) => d !== '.'))].sort();
|
|
const total = files.reduce((n, f) => n + statSync(join(dist, f)).size, 0);
|
|
|
|
console.log(`\n${files.length} files, ${(total / 1024).toFixed(0)} KB`);
|
|
console.log(` from ${dist}`);
|
|
console.log(` to ${URL_BASE}${DEST}\n`);
|
|
|
|
if (DRY) {
|
|
for (const d of dirs) console.log(` dir ${DEST}/${d}`);
|
|
for (const f of files) console.log(` file ${DEST}/${f}`);
|
|
console.log('\ndry run — nothing was sent');
|
|
} else {
|
|
if (!USER || !PASS) {
|
|
throw new Error(
|
|
'set FB_USER and FB_PASS.\n' +
|
|
" e.g. FB_USER=me FB_PASS='…' FB_INSECURE=1 npm run deploy:web\n" +
|
|
' or run with --dry-run to see what would be sent',
|
|
);
|
|
}
|
|
|
|
const cookie = await login();
|
|
const source = await resolveSource(cookie);
|
|
console.log(`logged in — writing to source "${source}"`);
|
|
|
|
await makeDir(cookie, source, DEST);
|
|
for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`);
|
|
|
|
let done = 0;
|
|
for (const f of files) {
|
|
await upload(cookie, source, join(dist, f), `${DEST}/${f}`);
|
|
done++;
|
|
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
|
|
}
|
|
|
|
console.log(`\nDeployed to FileBrowser at: ${URL_BASE}${DEST}`);
|
|
console.log(`Start9 Pages serves this website at: ${SITE_URL}`);
|
|
}
|