0.2.5-dev: a restore-defaults loadout sync is never overwritten by an older in-flight sync (PR #540 by @Xinglan233, ported); importing a loadout with ops is refused before writing when the DIY data is missing (PR #538 by @Xinglan233, ported); the player package's package.json keeps only scripts whose files ship (#531) and its docs link unshipped files to the release tag on GitHub (#530); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 14:45:01 +08:00
1 parent 7fd0478dc0
commit 52b70434d0
13 files changed
+610 -5

No files matched your search

+142
View File
@@ -35,6 +35,14 @@
// exactly the plan before zipping. Generated into the stage: packs/index.json, the pack index of the shipped packs
// (tools/packs.mjs writePackIndex — what the server's GET /packs/index.json answers, for a static host), when any pack
// ships (a language file of public/i18n/, a packs/<id>/pack.json).
// The staged package.json is not a byte copy of the repository file (GitHub #531). A script stays only when every
// `node <file>` it names is in the zip and it names at least one: start, dev, setup, doctor, launch, postinstall,
// vendor, assets. `npm test` (`node --test`, and test/ is not shipped), golden, build-data, lint, typecheck and this
// file are removed, so a player never gets a run that reports zero tests and exits 0. The same stage rewrites the
// shipped Markdown (GitHub #530): a relative link whose target is in the zip stays relative; a picture becomes
// <repo>/raw/v<tag>/<path>; another missing file becomes <repo>/blob/v<tag>/<path>; `../../releases/latest` becomes
// the repository releases/latest URL. The tag is `v` plus the package version with a trailing `-dev` removed. The
// copies in the git checkout stay relative.
// The account name comes from the OS at run time (never written in the repository): SP_PACKAGE_SCAN_USER=0 skips it
// (a name that is a common word), SP_PACKAGE_SCAN_NAMES=a,b adds more names to refuse.
//
@@ -93,6 +101,10 @@ export const shipsPacks = (files) => files.some((f) => /^public\/i18n\/[^/]+\.js
const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'public/vendor/'];
/** The npm scripts a player runs: each `node <file>` of them must ship. */
export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postinstall', 'vendor', 'assets'];
/** Markdown copied into the zip. Relative links to anything else are rewritten at stage time. */
export const PLAYER_MARKDOWN = [...ROOT_FILES, ...PLAYER_DOCS].filter((f) => f.endsWith('.md'));
/** Where a packaged doc link points when the file itself is not in the zip. */
export const GITHUB_REPO = 'https://github.com/sganggs/Stronghold-Protocol';
/**
* THE SWITCH for the Japanese voice dub in the full zip (`audio.voiceJp` of data/assets.json: 2674 files under
@@ -292,6 +304,116 @@ export function entryProblems(pkg, files) {
return out;
}
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp|svg)$/i;
const ABSOLUTE_OR_ANCHOR = /^(?:[a-z][a-z0-9+.-]*:|#)/i;
/** `node <file>` paths a script names. A bare `node --test` names none. */
export function playerScriptTargets(cmd) {
return [...String(cmd ?? '').matchAll(/\bnode\s+(?:--[\w-]+\s+)*([\w./-]+\.m?js)\b/g)].map((m) => m[1]);
}
/**
* Honest in a player zip: every `node <file>` it names is shipped, and it names at least one. `eslint`, `tsc` and
* `node --test` (no file, and test/ is not shipped) are not.
* @param {string} cmd
* @param {Set<string> | string[]} shipped
*/
export function playerScriptOk(cmd, shipped) {
const files = playerScriptTargets(cmd);
if (!files.length) return false;
const have = shipped instanceof Set ? shipped : new Set(shipped);
return files.every((f) => have.has(f));
}
/**
* package.json as the zip should ship it. Player scripts are always kept (entryProblems reports one whose file is
* missing). Any other script is kept only when playerScriptOk. Key order of the rest of the object stays.
* @param {object} pkg
* @param {Set<string> | string[]} shipped tracked player files (art is not a script target)
*/
export function playerPackageJson(pkg, shipped) {
const have = shipped instanceof Set ? shipped : new Set(shipped);
const scripts = {};
for (const [name, cmd] of Object.entries(pkg?.scripts || {})) {
if (PLAYER_NPM_SCRIPTS.includes(name) || playerScriptOk(cmd, have)) scripts[name] = cmd;
}
const next = {};
let sawScripts = false;
for (const [k, v] of Object.entries(pkg || {})) {
if (k === 'scripts') { next.scripts = scripts; sawScripts = true; }
else next[k] = v;
}
if (!sawScripts) next.scripts = scripts;
return next;
}
/** Problems in the package.json the zip would write. Empty when every remaining script starts shipped files. */
export function playerPackageProblems(pkg, files) {
const shipped = files instanceof Set ? files : new Set(files);
const next = playerPackageJson(pkg, shipped);
const out = [];
for (const name of PLAYER_NPM_SCRIPTS) {
if (pkg?.scripts?.[name] != null && next.scripts?.[name] == null) out.push(`packaged npm script "${name}" was dropped`);
}
for (const [name, cmd] of Object.entries(next.scripts || {})) {
if (!playerScriptOk(cmd, shipped)) out.push(`packaged npm run ${name} does not start shipped files`);
}
return out;
}
/**
* `v` + version, with a trailing `-dev` removed (`0.2.5-dev` → `v0.2.5`, `0.2.4` → `v0.2.4`).
* [ASSUMED] an unreleased `-dev` build points player docs at the tag that version will use.
* @param {string} version
*/
export function releaseTag(version) {
return `v${String(version ?? '').replace(/-dev$/, '')}`;
}
function docLinkPath(fromFile, href) {
const raw = String(href).split('#')[0];
const dir = path.posix.dirname(posixRel(fromFile));
return path.posix.normalize(path.posix.join(dir, raw));
}
/**
* The href a packaged copy of `fromFile` should use. Shipped targets stay relative (fragment included). Anything
* else in the repository becomes a GitHub blob (or raw, for a picture) at releaseTag(version). `../../releases/latest`
* becomes the repository's releases page. A different climb out of the repository throws.
* @param {string} href
* @param {string} fromFile path of the markdown file inside the zip
* @param {Set<string> | string[]} shipped
* @param {string} version package.json version
*/
export function playerDocHref(href, fromFile, shipped, version) {
if (ABSOLUTE_OR_ANCHOR.test(href)) return href;
const hash = href.includes('#') ? href.slice(href.indexOf('#')) : '';
const norm = docLinkPath(fromFile, href);
if (norm.startsWith('..') || path.posix.isAbsolute(norm)) {
const tail = norm.replace(/^(?:\.\.\/)+/, '');
if (tail === 'releases/latest') return `${GITHUB_REPO}/releases/latest${hash}`;
throw new Error(`packaged doc ${fromFile} links outside the repository: ${href}`);
}
const have = shipped instanceof Set ? shipped : new Set(shipped);
if (have.has(norm)) return href;
const kind = IMAGE_EXT.test(norm) ? 'raw' : 'blob';
return `${GITHUB_REPO}/${kind}/${releaseTag(version)}/${norm}${hash}`;
}
function rewriteDocLine(line, fromFile, shipped, version) {
return line.replace(/(!?\[[^\]]*\]\()([^)\s]+)\)/g, (_all, pre, href) => `${pre}${playerDocHref(href, fromFile, shipped, version)})`);
}
/** Rewrite inline links in a shipped markdown file. Fenced code blocks are left as written. */
export function rewritePlayerDoc(text, fromFile, shipped, version) {
let fenced = false;
return String(text).split('\n').map((line) => {
if (/^```/.test(line)) { fenced = !fenced; return line; }
if (fenced) return line;
return rewriteDocLine(line, fromFile, shipped, version);
}).join('\n');
}
/**
* Names refused in the personal scan: this machine's account name (unless SP_PACKAGE_SCAN_USER=0, a generic or short
* one) and SP_PACKAGE_SCAN_NAMES (comma-separated).
@@ -376,6 +498,7 @@ export function plan(root, opts = {}) {
if (!lite && !art.files.some((f) => f.startsWith('public/assets/'))) problems.push('no art under public/assets: run npm run setup first, or build --lite');
for (const p of importProblems(root, keep)) problems.push(p);
for (const p of entryProblems(pkg, keep)) problems.push(p);
for (const p of playerPackageProblems(pkg, keep)) problems.push(p);
if (!opts.allowDirty) for (const f of dirtyFiles(root, keep)) problems.push(`uncommitted change: ${f} (commit it, or --allow-dirty)`);
const names = scanNames(opts.env || process.env);
const modules = opts.measure !== false || (opts.scan !== false && names.length) ? prodModuleFiles(root, lock) : [];
@@ -496,6 +619,24 @@ function copyInto(from, to, rel) {
fs.chmodSync(dst, fs.statSync(src).mode & 0o777);
}
/** Rewrite the staged package.json and the shipped markdown. The git checkout is not touched. Throws on a bad link. */
function rewritePlayerStage(stage, p) {
const shipped = new Set([...(p.tracked || []), ...(p.generated || [])]);
const pkgPath = path.join(stage, 'package.json');
if (isFile(pkgPath)) {
const pkg = readJson(pkgPath);
if (!pkg) throw new Error('package.json in the stage is not JSON');
fs.writeFileSync(pkgPath, `${JSON.stringify(playerPackageJson(pkg, shipped), null, 2)}\n`);
}
for (const rel of PLAYER_MARKDOWN) {
const file = path.join(stage, rel);
if (!isFile(file)) continue;
const text = fs.readFileSync(file, 'utf8');
const next = rewritePlayerDoc(text, rel, shipped, p.version);
if (next !== text) fs.writeFileSync(file, next);
}
}
function freshTargets(targets, force) {
for (const t of targets) {
if (!fs.existsSync(t)) continue;
@@ -512,6 +653,7 @@ function makeStage(root, p, stage, { install, env, log }) {
const generated = new Set(p.generated || []);
log(`copying ${p.files.length - generated.size} files → ${stage}`);
for (const rel of p.files) if (!generated.has(rel)) copyInto(root, stage, rel);
rewritePlayerStage(stage, p);
if (generated.has(GENERATED_PACK_INDEX)) {
const index = writePackIndex(stage, path.join(stage, GENERATED_PACK_INDEX));
log(`${GENERATED_PACK_INDEX}: ${index.packs.map((x) => `${x.type} ${x.id}`).join(', ') || 'no pack'}`);