diff --git a/package.json b/package.json
index 7e9a3617..9bdd8850 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,9 @@
"launch": "node scripts/launch.mjs",
"lint": "eslint .",
"typecheck": "tsc --noEmit --checkJs -p jsconfig.json",
- "check:imports": "node tools/check-imports.mjs"
+ "check:imports": "node tools/check-imports.mjs",
+ "package": "node tools/package.mjs",
+ "package:lite": "node tools/package.mjs --lite"
},
"engines": {
"node": ">=22"
diff --git a/test/package.test.js b/test/package.test.js
new file mode 100644
index 00000000..b9dbeb56
--- /dev/null
+++ b/test/package.test.js
@@ -0,0 +1,155 @@
+// Player-package file selection. Dry-run only: no zip, no npm ci.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ extraPaths,
+ isExcluded,
+ packageOutIsUnsafe,
+ selectTracked,
+ textHasHomePath,
+ trackedFiles,
+} from '../tools/package.mjs';
+
+const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+const SAMPLE = [
+ 'server/index.js',
+ 'server/sim/rng.js',
+ 'shared/constants.js',
+ 'public/index.html',
+ 'public/js/main.js',
+ 'public/vendor/preact.mjs',
+ 'public/dev/game-mock.html',
+ 'public/assets/board.png',
+ 'public/fonts/fonts.css',
+ 'data/chess.json',
+ 'data/local-assets.json',
+ 'tools/setup.mjs',
+ 'tools/vendor.mjs',
+ 'tools/fetch-assets.mjs',
+ 'tools/assets/plan.mjs',
+ 'tools/local-extract/extract.py',
+ 'tools/golden.mjs',
+ 'tools/check-imports.mjs',
+ 'scripts/start.sh',
+ 'docs/PLAYING.md',
+ 'docs/DEPLOY.md',
+ 'docs/WINDOWS.md',
+ 'docs/ASSETS.md',
+ 'docs/img/combat.jpg',
+ 'docs/DESIGN.md',
+ 'docs/SIM.md',
+ 'docs/research/03-operators.json',
+ 'docs/research/10-networking-hosting.md',
+ 'test/version.test.js',
+ 'package.json',
+ 'package-lock.json',
+ 'LICENSE',
+ 'NOTICE.md',
+ 'THIRD-PARTY-NOTICES.md',
+ 'README.md',
+ 'CHANGELOG.md',
+ 'Dockerfile',
+ '.dockerignore',
+ 'AGENTS.md',
+ '.env',
+ '.env.local',
+ 'server/.env.production',
+ 'pv/clip.mp4',
+ '3,9,11回合情况/note.txt',
+ 'handoff/HANDOFF.md',
+ 'node_modules/ws/index.js',
+ '.cache/x',
+ 'eslint.config.js',
+ 'jsconfig.json',
+ 'types/core.js',
+];
+
+test('selectTracked keeps runtime files and drops the rest', () => {
+ const got = selectTracked(SAMPLE);
+ assert.deepEqual(got, selectTracked(SAMPLE, { lite: true }));
+ for (const rel of [
+ 'server/index.js', 'tools/setup.mjs', 'tools/vendor.mjs', 'docs/PLAYING.md',
+ 'docs/DEPLOY.md', 'public/index.html', 'data/chess.json', 'package.json',
+ 'Dockerfile', 'scripts/start.sh', 'tools/assets/plan.mjs', 'tools/local-extract/extract.py',
+ ]) assert.ok(got.includes(rel), rel);
+ for (const rel of [
+ 'tools/golden.mjs', 'tools/check-imports.mjs', 'test/version.test.js', 'public/dev/game-mock.html',
+ 'public/assets/board.png', 'public/fonts/fonts.css', 'docs/research/10-networking-hosting.md',
+ 'docs/research/03-operators.json', 'docs/DESIGN.md', 'docs/SIM.md', 'AGENTS.md', '.env',
+ '.env.local', 'server/.env.production', 'pv/clip.mp4', '3,9,11回合情况/note.txt',
+ 'handoff/HANDOFF.md', 'node_modules/ws/index.js', 'data/local-assets.json', 'eslint.config.js',
+ 'types/core.js',
+ ]) assert.ok(!got.includes(rel), rel);
+ assert.equal(isExcluded('docs/research/10-networking-hosting.md'), true);
+});
+
+test('lite extras skip art, fonts and the local manifest', () => {
+ assert.deepEqual(extraPaths({ lite: true, hasLocalManifest: true }), ['public/vendor']);
+ assert.deepEqual(extraPaths({ lite: false, hasLocalManifest: true }), [
+ 'public/vendor', 'public/assets', 'public/fonts', 'data/local-assets.json',
+ ]);
+ assert.deepEqual(extraPaths({ lite: false, hasLocalManifest: false }), [
+ 'public/vendor', 'public/assets', 'public/fonts',
+ ]);
+});
+
+test('home-directory text and unsafe output directories', () => {
+ assert.equal(textHasHomePath('see /Users/example/game'), 'mac-users');
+ assert.equal(textHasHomePath('C:\\Users\\example\\game'), 'win-users');
+ assert.equal(textHasHomePath('prefix /home/example/game'), 'posix-home');
+ assert.equal(textHasHomePath('https://github.com/example/game'), null);
+ assert.equal(packageOutIsUnsafe(ROOT, ROOT), true);
+ assert.equal(packageOutIsUnsafe(path.dirname(ROOT), ROOT), true);
+ assert.equal(packageOutIsUnsafe(path.join(ROOT, 'dist-pack'), ROOT), false);
+});
+
+test('real git ls-files: runtime in, dev and private out', () => {
+ const got = new Set(selectTracked(trackedFiles(ROOT)));
+ assert.ok(got.has('server/index.js'));
+ assert.ok(got.has('tools/setup.mjs'));
+ assert.ok(got.has('docs/PLAYING.md'));
+ assert.ok(got.has('package-lock.json'));
+ assert.equal(got.has('tools/golden.mjs'), false);
+ assert.equal(got.has('tools/package.mjs'), false);
+ assert.equal(got.has('test/golden.test.js'), false);
+ assert.equal(got.has('docs/DESIGN.md'), false);
+ assert.equal(got.has('eslint.config.js'), false);
+ for (const rel of got) {
+ assert.equal(rel.startsWith('docs/research/'), false, rel);
+ assert.equal(rel.startsWith('test/'), false, rel);
+ assert.equal(rel.startsWith('public/dev/'), false, rel);
+ assert.equal(rel.startsWith('handoff/'), false, rel);
+ }
+});
+
+test('dry-run lists the lite set and prints both size estimates', () => {
+ const lite = spawnSync(process.execPath, ['tools/package.mjs', '--dry-run', '--lite'], {
+ cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
+ });
+ assert.equal(lite.status, 0, lite.stderr);
+ assert.match(lite.stdout, /^mode: lite/m);
+ assert.match(lite.stdout, /^dry-run: yes/m);
+ assert.match(lite.stdout, /^file server\/index\.js$/m);
+ assert.match(lite.stdout, /^file tools\/setup\.mjs$/m);
+ assert.match(lite.stdout, /^file docs\/PLAYING\.md$/m);
+ assert.match(lite.stdout, /^extra public\/vendor$/m);
+ assert.doesNotMatch(lite.stdout, /^file tools\/golden\.mjs$/m);
+ assert.doesNotMatch(lite.stdout, /^file test\//m);
+ assert.doesNotMatch(lite.stdout, /^extra public\/assets$/m);
+ assert.match(lite.stdout, /^uncompressed lite: \d+/m);
+ assert.match(lite.stdout, /^uncompressed full estimate: \d+/m);
+ assert.match(lite.stdout, /puppeteer-core/);
+ const full = spawnSync(process.execPath, ['tools/package.mjs', '--dry-run'], {
+ cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
+ });
+ assert.equal(full.status, 0, full.stderr);
+ assert.match(full.stdout, /^mode: full/m);
+ assert.match(full.stdout, /^extra public\/assets$/m);
+ assert.match(full.stdout, /^extra public\/fonts$/m);
+ const assets = Number((full.stdout.match(/^assets bytes: (\d+)/m) || [])[1]);
+ assert.ok(assets > 300 * 1048576, `assets bytes ${assets}`);
+});
diff --git a/tools/package.mjs b/tools/package.mjs
new file mode 100644
index 00000000..3359c8d6
--- /dev/null
+++ b/tools/package.mjs
@@ -0,0 +1,343 @@
+// Player package: git-tracked runtime files, production dependencies, and (unless --lite) local art.
+//
+// scripts/make-windows-bundle.mjs is the Windows portable-Node pack. This tool is the release zip:
+// a runtime allowlist, not the whole tree. The Docker image still copies docs/research as a sim
+// fallback; this zip does not. Players on --lite run `npm run setup` to download art and fonts.
+//
+// node tools/package.mjs [--dry-run] [--lite] [--out
] [--no-install]
+//
+// --dry-run prints the file list and a size summary and writes nothing.
+// A real full zip is large because public/assets is included. Do not point --out at the repo.
+
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+const ROOT_FILES = new Set([
+ 'package.json', 'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md',
+ 'README.md', 'CHANGELOG.md', 'Dockerfile', '.dockerignore',
+]);
+
+const PLAYER_DOCS = new Set([
+ 'docs/PLAYING.md', 'docs/DEPLOY.md', 'docs/WINDOWS.md', 'docs/ASSETS.md',
+]);
+
+const RUNTIME_TOOLS = new Set([
+ 'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs',
+ 'tools/doctor.mjs', 'tools/crop-board-atlas.mjs',
+]);
+
+const DROP_PREFIXES = [
+ 'test/', 'public/dev/', 'public/assets/', 'public/fonts/', 'docs/research/',
+ 'handoff/', 'node_modules/', '.cache/', 'pv/', '3,9,11回合情况/',
+];
+
+const TEXT_EXT = new Set([
+ '.js', '.mjs', '.cjs', '.json', '.md', '.html', '.css', '.txt', '.yml', '.yaml',
+ '.ps1', '.sh', '.cmd', '.bat', '.py', '.svg',
+]);
+
+const HOME_PATTERNS = [
+ { name: 'mac-users', re: /\/Users\// },
+ { name: 'win-users', re: /[A-Za-z]:\\Users\\/ },
+ { name: 'posix-home', re: /\/home\// },
+];
+
+export function posixRel(rel) {
+ return String(rel).split('\\').join('/');
+}
+
+/** True when the path must never ship, even if an allow rule would match. */
+export function isExcluded(rel) {
+ const p = posixRel(rel);
+ const base = p.slice(p.lastIndexOf('/') + 1);
+ if (base === '.env' || base.startsWith('.env.')) return true;
+ if (p === 'AGENTS.md' || p === 'docs/research/10-networking-hosting.md') return true;
+ return DROP_PREFIXES.some((pre) => p === pre.slice(0, -1) || p.startsWith(pre));
+}
+
+/** Git-tracked paths that belong in the player package. `lite` does not change this set. */
+export function selectTracked(paths, _opts = {}) {
+ const out = [];
+ for (const raw of paths) {
+ const rel = posixRel(raw);
+ if (!rel || isExcluded(rel)) continue;
+ if (allowed(rel)) out.push(rel);
+ }
+ out.sort();
+ return out;
+}
+
+function allowed(rel) {
+ if (rel.startsWith('server/') || rel.startsWith('shared/')) return true;
+ if (rel.startsWith('public/')) return true;
+ if (rel.startsWith('data/')) return rel !== 'data/local-assets.json';
+ if (rel.startsWith('scripts/')) return true;
+ if (RUNTIME_TOOLS.has(rel)) return true;
+ if (rel.startsWith('tools/assets/') || rel.startsWith('tools/local-extract/')) return true;
+ if (PLAYER_DOCS.has(rel) || rel.startsWith('docs/img/')) return true;
+ return ROOT_FILES.has(rel);
+}
+
+/** Directories and files copied in addition to the git allowlist. */
+export function extraPaths({ lite, hasLocalManifest }) {
+ const paths = ['public/vendor'];
+ if (!lite) {
+ paths.push('public/assets', 'public/fonts');
+ if (hasLocalManifest) paths.push('data/local-assets.json');
+ }
+ return paths;
+}
+
+export function textHasHomePath(text) {
+ for (const { name, re } of HOME_PATTERNS) {
+ if (re.test(text)) return name;
+ }
+ return null;
+}
+
+/** True when `out` is the repo or a parent of it (a wipe there would take the repo with it). */
+export function packageOutIsUnsafe(out, root = ROOT) {
+ const r = path.resolve(root);
+ const o = path.resolve(out);
+ if (o === r) return true;
+ const rel = path.relative(o, r);
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
+}
+
+export function trackedFiles(root = ROOT) {
+ const r = spawnSync('git', ['-C', root, 'ls-files', '-z'], { maxBuffer: 32 * 1024 * 1024 });
+ if (r.error || r.status !== 0) {
+ throw new Error('git ls-files failed; package only tracked runtime files, so run it inside the repo');
+ }
+ return r.stdout.toString('utf8').split('\0').filter(Boolean);
+}
+
+function fileBytes(abs) {
+ try {
+ const st = fs.statSync(abs);
+ return st.isFile() ? st.size : 0;
+ } catch {
+ return 0;
+ }
+}
+
+/** Bytes under abs, following symlinks. Skips nested node_modules and .cache. */
+export function treeBytes(abs) {
+ let total = 0;
+ const seen = new Set();
+ function walk(dir) {
+ let real;
+ try { real = fs.realpathSync(dir); } catch { return; }
+ if (seen.has(real)) return;
+ seen.add(real);
+ let entries;
+ try { entries = fs.readdirSync(real, { withFileTypes: true }); } catch { return; }
+ for (const ent of entries) {
+ if (ent.name === 'node_modules' || ent.name === '.cache') continue;
+ const p = path.join(real, ent.name);
+ let st;
+ try { st = fs.statSync(p); } catch { continue; }
+ if (st.isDirectory()) walk(p);
+ else if (st.isFile()) total += st.size;
+ }
+ }
+ try {
+ const st = fs.statSync(abs);
+ if (st.isFile()) return st.size;
+ if (st.isDirectory()) walk(abs);
+ } catch {
+ return 0;
+ }
+ return total;
+}
+
+/** Uncompressed size of production lockfile entries present under root/node_modules. Dev entries are skipped. */
+export function prodModulesBytes(root, lock) {
+ let total = 0;
+ const packages = lock?.packages || {};
+ for (const [key, entry] of Object.entries(packages)) {
+ if (!key || entry.dev) continue;
+ total += treeBytes(path.join(root, key));
+ }
+ return total;
+}
+
+function exists(root, rel) {
+ try { return fs.statSync(path.join(root, rel)).isFile() || fs.statSync(path.join(root, rel)).isDirectory(); }
+ catch { return false; }
+}
+
+/**
+ * Selection plus uncompressed byte counts. Does not copy or install.
+ * @param {string} root
+ * @param {{ lite?: boolean, paths?: string[] }} [opts]
+ */
+export function measure(root, opts = {}) {
+ const lite = !!opts.lite;
+ const paths = opts.paths || trackedFiles(root);
+ const tracked = selectTracked(paths, { lite });
+ let trackedBytes = 0;
+ for (const rel of tracked) trackedBytes += fileBytes(path.join(root, rel));
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
+ const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'));
+ const hasLocal = exists(root, 'data/local-assets.json');
+ const vendorBytes = treeBytes(path.join(root, 'public', 'vendor'));
+ const fontsBytes = treeBytes(path.join(root, 'public', 'fonts'));
+ const assetsBytes = treeBytes(path.join(root, 'public', 'assets'));
+ const manifestBytes = hasLocal ? fileBytes(path.join(root, 'data', 'local-assets.json')) : 0;
+ const prodBytes = prodModulesBytes(root, lock);
+ const liteBytes = trackedBytes + vendorBytes + prodBytes;
+ const fullBytes = liteBytes + fontsBytes + assetsBytes + manifestBytes;
+ return {
+ version: pkg.version,
+ lite,
+ tracked,
+ extras: extraPaths({ lite, hasLocalManifest: hasLocal }),
+ trackedBytes,
+ vendorBytes,
+ fontsBytes,
+ assetsBytes,
+ manifestBytes,
+ prodBytes,
+ liteBytes,
+ fullBytes,
+ prodDeps: Object.keys(pkg.dependencies || {}).sort(),
+ devDeps: Object.keys(pkg.devDependencies || {}).sort(),
+ assetsPresent: exists(root, 'public/assets'),
+ fontsPresent: exists(root, 'public/fonts'),
+ vendorPresent: exists(root, 'public/vendor'),
+ };
+}
+
+export function formatSummary(m) {
+ const mb = (n) => `${(n / 1048576).toFixed(1)} MB`;
+ const lines = [
+ `mode: ${m.lite ? 'lite' : 'full'}`,
+ `version: ${m.version}`,
+ `tracked files: ${m.tracked.length}`,
+ `tracked bytes: ${m.trackedBytes}`,
+ `vendor bytes: ${m.vendorBytes}${m.vendorPresent ? '' : ' (absent)'}`,
+ `fonts bytes: ${m.fontsBytes}${m.lite ? ' (omitted in lite)' : ''}`,
+ `assets bytes: ${m.assetsBytes}${m.lite ? ' (omitted in lite)' : ''}`,
+ `local manifest bytes: ${m.manifestBytes}${m.lite ? ' (omitted in lite)' : ''}`,
+ `production node_modules estimate: ${m.prodBytes}`,
+ `uncompressed lite: ${m.liteBytes} (${mb(m.liteBytes)})`,
+ `uncompressed full estimate: ${m.fullBytes} (${mb(m.fullBytes)})`,
+ `production dependencies: ${m.prodDeps.join(' ')}`,
+ `omitted devDependencies: ${m.devDeps.join(' ')}`,
+ ];
+ return lines.join('\n') + '\n';
+}
+
+function copyRel(root, stage, rel) {
+ const src = path.join(root, rel);
+ const dest = path.join(stage, rel);
+ if (!fs.existsSync(src)) throw new Error(`missing ${rel}`);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.cpSync(src, dest, { dereference: true, recursive: true });
+}
+
+function scanStage(stage) {
+ const hits = [];
+ function walk(dir, rel) {
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
+ for (const ent of entries) {
+ const child = rel ? `${rel}/${ent.name}` : ent.name;
+ if (child === 'node_modules' || child.startsWith('node_modules/') || child.startsWith('public/assets') || child.startsWith('public/fonts')) continue;
+ const abs = path.join(dir, ent.name);
+ if (ent.isDirectory()) { walk(abs, child); continue; }
+ if (!ent.isFile()) continue;
+ const ext = path.extname(ent.name).toLowerCase();
+ const named = ent.name === 'Dockerfile' || ent.name === '.dockerignore';
+ if (!named && !TEXT_EXT.has(ext)) continue;
+ let text;
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
+ const hit = textHasHomePath(text);
+ if (hit) hits.push(`${child} (${hit})`);
+ }
+ }
+ walk(stage, '');
+ return hits;
+}
+
+function build(root, m, { out, noInstall }) {
+ if (packageOutIsUnsafe(out, root)) throw new Error(`refusing to write the package into the repo or a parent of it: ${out}`);
+ const folder = `Stronghold-Protocol-${m.version}${m.lite ? '-lite' : ''}`;
+ const stage = path.join(out, folder);
+ if (fs.existsSync(stage)) throw new Error(`package directory already exists: ${stage}`);
+ if (!m.lite) {
+ if (!m.assetsPresent) throw new Error('public/assets is missing; a full package needs the local art (or pass --lite)');
+ if (!m.fontsPresent) throw new Error('public/fonts is missing; a full package needs the local fonts (or pass --lite)');
+ }
+ fs.mkdirSync(stage, { recursive: true });
+ for (const rel of m.tracked) copyRel(root, stage, rel);
+ for (const rel of m.extras) {
+ if (!fs.existsSync(path.join(root, rel))) {
+ if (rel === 'data/local-assets.json') continue;
+ if (rel === 'public/vendor' && !noInstall) continue; // npm ci postinstall (tools/vendor.mjs) fills it
+ throw new Error(`missing ${rel}`);
+ }
+ copyRel(root, stage, rel);
+ }
+ if (!noInstall) {
+ const r = spawnSync('npm', ['ci', '--omit=dev', '--no-audit', '--no-fund'], { cwd: stage, stdio: 'inherit' });
+ if (r.error || r.status !== 0) throw new Error('npm ci --omit=dev failed in the package directory');
+ fs.rmSync(path.join(stage, 'node_modules', '.cache'), { recursive: true, force: true });
+ }
+ const hits = scanStage(stage);
+ if (hits.length) {
+ throw new Error(`refusing to pack; home-directory paths in:\n${hits.join('\n')}`);
+ }
+ const zipPath = path.join(out, `${folder}.zip`);
+ const zip = spawnSync('zip', ['-r', '-q', zipPath, folder], { cwd: out });
+ if (zip.error || zip.status !== 0) throw new Error('zip failed');
+ return { stage, zipPath };
+}
+
+function main(argv) {
+ let lite = false;
+ let dry = false;
+ let noInstall = false;
+ let out = '';
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--lite') lite = true;
+ else if (a === '--dry-run') dry = true;
+ else if (a === '--no-install') noInstall = true;
+ else if (a === '--out') {
+ out = argv[++i];
+ if (!out || out.startsWith('--')) {
+ console.error('package: --out needs a directory');
+ return 1;
+ }
+ } else if (a === '--help' || a === '-h') {
+ console.log('usage: node tools/package.mjs [--dry-run] [--lite] [--out ] [--no-install]');
+ return 0;
+ } else {
+ console.error(`package: unknown argument ${a}`);
+ return 1;
+ }
+ }
+ const m = measure(ROOT, { lite });
+ process.stdout.write(formatSummary(m));
+ if (dry) {
+ process.stdout.write('dry-run: yes\n');
+ for (const rel of m.tracked) process.stdout.write(`file ${rel}\n`);
+ for (const rel of m.extras) process.stdout.write(`extra ${rel}\n`);
+ return 0;
+ }
+ const dest = out || path.join(os.tmpdir(), `stronghold-protocol-package-${m.version}`);
+ const built = build(ROOT, m, { out: dest, noInstall });
+ console.log(`zip: ${built.zipPath}`);
+ return 0;
+}
+
+const invoked = process.argv[1]
+ && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
+if (invoked) process.exit(main(process.argv.slice(2)));