diff --git a/scripts/launch.mjs b/scripts/launch.mjs
index d8f28216..b4eaf1c4 100755
--- a/scripts/launch.mjs
+++ b/scripts/launch.mjs
@@ -5,9 +5,11 @@
// node scripts/launch.mjs [--port 3000] [--host 0.0.0.0] [--no-open] [--no-setup] [setup options…]
//
// 1. If our server already answers on the port, just open the browser (double-clicking twice is harmless).
-// 2. node tools/setup.mjs --quiet (dependencies, vendor libs, art download / resume, optional local extraction);
+// 2. An update package extracted over the folder (UPDATE.json) is finished (server/update.js; the server does it too,
+// for the routes that skip this script); one that does not fit this install stops here with the message.
+// 3. node tools/setup.mjs --quiet (dependencies, vendor libs, art download / resume, optional local extraction);
// setup options such as --no-assets, --no-local, --local, --game
, -y are passed through.
-// 3. node server/index.js (PORT / HOST from the options or the environment; SP_COMBAT / SP_VERIFY / TRUST_PROXY /
+// 4. node server/index.js (PORT / HOST from the options or the environment; SP_COMBAT / SP_VERIFY / TRUST_PROXY /
// DEBUG are inherited), then — once /healthz answers — prints the addresses to share and opens
// http://localhost: (not with --no-open, SP_NO_BROWSER=1, or on a Linux box without a display).
// Ctrl+C stops the server (it gets the signal from the terminal itself); the exit code is the server's.
@@ -29,6 +31,7 @@ if (Number(process.versions.node.split('.')[0]) < 22) {
const { c, mark } = await import('../tools/setup.mjs');
const { probePort, classifyAddresses, KIND_LABEL } = await import('../tools/doctor.mjs');
+const { applyPendingUpdate, UPDATE_FILE } = await import('../server/update.js');
function parseArgs(argv) {
const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false };
@@ -81,7 +84,7 @@ async function main() {
const o = parseArgs(process.argv.slice(2));
if (o.help) {
const src = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n');
- console.log(src.slice(1, 13).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
+ console.log(src.slice(1, 15).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
return 0;
}
const localUrl = `http://localhost:${o.port}`;
@@ -89,6 +92,9 @@ async function main() {
const before = await probePort(o.port, o.host);
if (before.state === 'ours') {
console.log(`${mark.ok} 服务器已经在运行(端口 ${o.port}),直接打开浏览器。`);
+ if (fs.existsSync(path.join(ROOT, UPDATE_FILE))) {
+ console.log(c.warn(` 解压过的更新包还没有应用:正在运行的仍是旧版本。先停止它(关掉它的窗口;开机自启用 install-service-windows.ps1 -Restart),再重新启动即可。`));
+ }
printShare(o.port);
if (o.open) openBrowser(localUrl);
return 0;
@@ -99,6 +105,9 @@ async function main() {
return 1;
}
+ // before setup: setup and the server then see the new version's files (its message explains a refusal)
+ if (applyPendingUpdate(ROOT).state === 'failed') return 1;
+
if (o.setup) {
const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'setup.mjs'), '--quiet', ...o.setupArgs], { cwd: ROOT, stdio: 'inherit' });
if (r.status !== 0) { console.error(`${mark.err} 准备步骤失败(见上方)。`); return r.status || 1; }
diff --git a/server/http/boot.js b/server/http/boot.js
index ee862e2b..f1d3eb0a 100644
--- a/server/http/boot.js
+++ b/server/http/boot.js
@@ -1,5 +1,8 @@
-// server/http/boot.js — running the server as a process (`node server/index.js`, npm start, scripts/launch.mjs):
+// server/http/boot.js — running the server as a process (`node server/index.js`, npm start, scripts/launch.mjs, the
+// Windows service's scripts/run-server.cmd, NSSM, systemd):
//
+// * first, an update package extracted over the install (UPDATE.json) is finished — old files deleted, the install
+// verified (server/update.js); one that does not match its MANIFEST.json keeps the server from starting (exit 1);
// * the boot banner: release version, the Local URL, the LAN URLs when listening on every interface, the
// cloudflared command for internet play;
// * a server that cannot start exits 1 (with a hint when the port is in use); unhandled rejections and uncaught
@@ -11,6 +14,8 @@ import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { APP_VERSION, DEV_BUILD } from '../../shared/constants.js';
+import { applyPendingUpdate } from '../update.js';
+import { ROOT } from './config.js';
/** Non-internal IPv4 addresses as http URLs. @param {number} port */
export function lanUrls(port) {
@@ -45,12 +50,16 @@ export function printBanner(srv) {
}
/**
- * The process main: start the server, print the banner, stop gracefully on SIGINT / SIGTERM.
+ * The process main: finish a pending update package, start the server, print the banner, stop gracefully on SIGINT /
+ * SIGTERM.
* @param {() => Promise<{ url: string, host: string, port: number, close: () => Promise }>} start index.js startServer
*/
export async function runMain(start) {
process.on('unhandledRejection', (e) => console.error('[process] unhandled rejection', e));
process.on('uncaughtException', (e) => console.error('[process] uncaught exception', e));
+ // before the data, the packs or the browser runtime are read: the files must be the new version's (server/update.js).
+ // Nothing is listening yet, so returning ends the process with exit code 1 once the message is written.
+ if (applyPendingUpdate(ROOT).state === 'failed') { process.exitCode = 1; return; }
let srv;
try {
srv = await start();
diff --git a/server/index.js b/server/index.js
index b2ba50d6..440d2664 100644
--- a/server/index.js
+++ b/server/index.js
@@ -12,7 +12,9 @@
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
-// http/boot.js banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT / SIGTERM
+// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
+// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
+// SIGTERM
//
// Per-network limits for internet clients (see net.js clientAddress; local/LAN peers are exempt): open sockets
// (maxConnectionsPerAddr, refused at upgrade with 429), rooms and running matches (lobby.js).
diff --git a/server/update.js b/server/update.js
new file mode 100644
index 00000000..cba56086
--- /dev/null
+++ b/server/update.js
@@ -0,0 +1,331 @@
+// server/update.js — the update package (docs/DEPLOY.md §1.5 and §7): the boot step that finishes an update extracted
+// over an install, and the install check `npm run doctor` shows. Node builtins only, so it loads whatever else the
+// install holds.
+//
+// Every release zip (full, lite, update) carries MANIFEST.json at its root: {format, app, files: {: {size,
+// sha256}}} — every shipped file except the art setup manages (public/assets/, public/fonts/, data/assets.json,
+// data/local-assets.json; the lite zip has none of it): the code, the data, node_modules, public/vendor, the docs. The
+// update zip (Stronghold-Protocol-v-update.zip, `node tools/package.mjs --update --from `)
+// holds only the files that differ from at least one of those zips, MANIFEST.json and UPDATE.json:
+// {format, app, from: [], count, bytes, files: {: {size, sha256}} (what it ships),
+// removed: [{path, sha256: []}] (files a base shipped that this version does not)}.
+//
+// The player stops the server, extracts the zip over the install and starts it again. applyPendingUpdate runs before
+// the server starts (server/http/boot.js runMain — every route: npm start, scripts/launch.mjs, the Windows service's
+// run-server.cmd, NSSM, systemd; scripts/launch.mjs runs it once more before setup):
+// 1. UPDATE.json unreadable or malformed (an unknown format, a path outside the install …) → logged, left in place,
+// nothing deleted: the server starts with the files as they are.
+// 2. The install is verified against MANIFEST.json (and the art files the update brought). A file the server or the
+// browser runs that is missing or holds other bytes → the bilingual "this update only applies over v… — download
+// the full package" message, nothing deleted, UPDATE.json kept (the next start checks again) and the server does
+// not start: a mix of two versions fails in ways that look like game bugs (a stale module, old data under new
+// code). Differences only in docs, licences, scripts/ or tools/ are named, and the update counts as applied.
+// 3. Each `removed` file is deleted when it still holds bytes a base shipped — a file changed on this machine, a pack
+// the player installed, a symlink, a folder, a path outside the install or a per-machine name (.env, .venv*, logs,
+// the caches, the service settings) is never touched — then the folders it leaves empty.
+// 4. UPDATE.json is renamed to .update-applied.json; the next start finds nothing to do.
+// No network, no automatic update: the update zip is a manual download from GitHub Releases, the same trust as the
+// full zip (whose code runs anyway).
+
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+
+export const MANIFEST_FILE = 'MANIFEST.json';
+export const UPDATE_FILE = 'UPDATE.json';
+export const APPLIED_FILE = '.update-applied.json';
+/** The format of MANIFEST.json and UPDATE.json this code writes and reads. */
+export const UPDATE_FORMAT = 1;
+const META = new Set([MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]);
+
+/** The art setup downloads or extracts (and the lite zip leaves out): never in MANIFEST.json, never fatal. */
+export const isSetupArt = (rel) => rel.startsWith('public/assets/') || rel.startsWith('public/fonts/')
+ || rel === 'data/assets.json' || rel === 'data/local-assets.json';
+/** Whether MANIFEST.json lists a shipped file. */
+export const inManifest = (rel) => !META.has(rel) && !isSetupArt(rel);
+
+const DOC = /(?:^|\/)(?:LICEN[CS]E|COPYING|NOTICE|AUTHORS|CHANGELOG|CHANGES|HISTORY|README)(?:[.-][^/]*)?$|\.(?:md|markdown|txt|map|d\.[cm]?ts)$/i;
+const CODE = /\.(?:[cm]?js|json|wasm|node|css|html?)$/i;
+/**
+ * Whether the server or the browser runs (or serves) a file: a missing or different one stops an update. Docs and
+ * licences anywhere (README, LICENSE, *.md, *.txt, *.d.ts, source maps), scripts/, tools/ (setup, doctor — not the
+ * server), package-lock.json and npm's own node_modules/.package-lock.json only get a warning.
+ */
+export function affectsRuntime(rel) {
+ if (rel.startsWith('scripts/') || rel.startsWith('tools/')) return false;
+ if (rel === 'package-lock.json' || rel === 'node_modules/.package-lock.json') return false;
+ return CODE.test(rel) || !DOC.test(rel);
+}
+
+/** Size and SHA-256 (hex) of a regular file, or null. */
+export function digestFile(abs) {
+ let buf;
+ try { buf = fs.readFileSync(abs); } catch { return null; }
+ return { size: buf.length, sha256: crypto.createHash('sha256').update(buf).digest('hex') };
+}
+
+/**
+ * The '/assets/…' and '/fonts/…' URLs of an art manifest (data/assets.json, data/local-assets.json) as public/… paths
+ * (the walk of tools/setup.mjs checkAssets; tools/package.mjs plans the full zip's art with it).
+ */
+export function listedArtFiles(manifest) {
+ const out = new Set();
+ const walk = (v) => {
+ if (typeof v === 'string') {
+ if (/^\/(?:assets|fonts)\//.test(v)) out.add(`public/${v.split('/').filter(Boolean).map(decodeURIComponent).join('/')}`);
+ } else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x);
+ };
+ walk(manifest);
+ return out;
+}
+
+/** Per-machine places an update never deletes from (the user part of tools/package.mjs's refusal list). */
+const KEEP = ['.git', '.cache', '.claude', 'logs', 'handoff', 'node_modules/.cache', 'scripts/service.env.cmd'];
+
+/**
+ * Why a path of MANIFEST.json / UPDATE.json cannot be used (null when it can): it must be relative, in `/` form and
+ * inside the install — no `..` or `.` part, no empty part, no part ending in a dot or a space, no backslash, drive or
+ * stream colon, control or wildcard character.
+ */
+export function pathProblem(rel) {
+ if (typeof rel !== 'string' || !rel) return 'not a path';
+ if (rel.length > 1024) return 'too long';
+ if (/[\x00-\x1f\\:*?"<>|]/.test(rel)) return 'not a plain relative path';
+ if (rel.startsWith('/')) return 'an absolute path';
+ if (rel.split('/').some((s) => s === '' || s === '.' || s === '..' || /[. ]$/.test(s))) return 'outside the install (an empty, . or .. part)';
+ return null;
+}
+
+/**
+ * Why an update may not delete a path (null when it may): pathProblem's rules, and never a per-machine file (`.env`,
+ * `.venv*`, the caches, logs, the Windows service settings) or one of the update's own files.
+ */
+export function removalProblem(rel) {
+ const bad = pathProblem(rel);
+ if (bad) return bad;
+ if (META.has(rel)) return 'an update file';
+ const parts = rel.split('/');
+ const base = parts[parts.length - 1];
+ if (base === '.env' || base.startsWith('.env.') || parts.some((s) => s.startsWith('.venv'))) return 'a per-machine file';
+ if (KEEP.some((k) => rel === k || rel.startsWith(`${k}/`))) return 'in a per-machine folder';
+ return null;
+}
+
+const HEX64 = /^[0-9a-f]{64}$/;
+const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
+const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
+const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
+
+/** {path: {size, sha256}} → Map, or throws. */
+function parseFiles(v, what) {
+ if (!isObj(v)) throw new Error(`${what} is not an object`);
+ const out = new Map();
+ for (const [rel, d] of Object.entries(v)) {
+ const bad = pathProblem(rel);
+ if (bad) throw new Error(`${what}: ${rel} is ${bad}`);
+ if (!isObj(d) || !isCount(d.size) || typeof d.sha256 !== 'string' || !HEX64.test(d.sha256)) throw new Error(`${what}: ${rel} has no size / sha256`);
+ out.set(rel, { size: d.size, sha256: d.sha256 });
+ }
+ return out;
+}
+
+function parseHead(text, what) {
+ const j = JSON.parse(text);
+ if (!isObj(j)) throw new Error(`${what} is not an object`);
+ if (j.format !== UPDATE_FORMAT) throw new Error(`${what} has format ${JSON.stringify(j.format)}, this server reads ${UPDATE_FORMAT}`);
+ if (typeof j.app !== 'string' || !VERSION.test(j.app)) throw new Error(`${what} names no version`);
+ return j;
+}
+
+/** MANIFEST.json's text → {app, files: Map}, or throws with the reason. */
+export function parseManifest(text) {
+ const j = parseHead(text, MANIFEST_FILE);
+ return { app: j.app, files: parseFiles(j.files, 'files') };
+}
+
+/** UPDATE.json's text → {app, from, count, bytes, files: Map, removed: [{path, sha256}]}, or throws with the reason. */
+export function parseUpdate(text) {
+ const j = parseHead(text, UPDATE_FILE);
+ if (!Array.isArray(j.from) || !j.from.length || !j.from.every((v) => typeof v === 'string' && VERSION.test(v))) throw new Error('from names no base version');
+ if (!isCount(j.count) || !isCount(j.bytes)) throw new Error('count / bytes missing');
+ const files = parseFiles(j.files, 'files');
+ if (!Array.isArray(j.removed)) throw new Error('removed is not a list');
+ const removed = [];
+ const seen = new Set();
+ for (const r of j.removed) {
+ const rel = isObj(r) ? r.path : undefined;
+ const bad = removalProblem(rel);
+ if (bad) throw new Error(`removed: ${typeof rel === 'string' ? rel : JSON.stringify(rel)} is ${bad}`);
+ if (seen.has(rel) || files.has(rel)) throw new Error(`removed: ${rel} is listed twice or also shipped`);
+ if (!Array.isArray(r.sha256) || !r.sha256.length || !r.sha256.every((h) => typeof h === 'string' && HEX64.test(h))) throw new Error(`removed: ${rel} has no sha256`);
+ seen.add(rel);
+ removed.push({ path: rel, sha256: [...r.sha256] });
+ }
+ return { app: j.app, from: [...j.from], count: j.count, bytes: j.bytes, files, removed };
+}
+
+const abs = (root, rel) => path.join(root, ...rel.split('/'));
+
+/** Compare the files on disk with a Map of {size, sha256}: the paths missing (or not a file) and those with other bytes. */
+export function verifyFiles(root, files) {
+ const missing = [];
+ const mismatched = [];
+ for (const [rel, want] of files) {
+ const p = abs(root, rel);
+ let st;
+ try { st = fs.statSync(p); } catch { missing.push(rel); continue; }
+ if (!st.isFile()) { missing.push(rel); continue; }
+ if (st.size !== want.size) { mismatched.push(rel); continue; }
+ const got = digestFile(p);
+ if (!got || got.sha256 !== want.sha256) mismatched.push(rel);
+ }
+ return { checked: files.size, missing, mismatched };
+}
+
+const inside = (root, p) => { const r = path.relative(root, p); return r !== '' && !r.startsWith('..') && !path.isAbsolute(r); };
+
+/**
+ * Delete the `removed` files that still hold bytes a base shipped, then the folders that leaves empty. `keep`: the
+ * lower-cased paths of the new version — a removed path that differs from one only in case is the same file on
+ * Windows / macOS.
+ * @param {string} root
+ * @param {{ path: string, sha256: string[] }[]} removed
+ * @param {Set} [keep]
+ * @returns {{ deleted: string[], kept: string[], skipped: string[], absent: number }}
+ */
+export function deleteRemoved(root, removed, keep = new Set()) {
+ const base = path.resolve(root);
+ let real;
+ try { real = fs.realpathSync(base); } catch { real = base; }
+ const out = { deleted: [], kept: [], skipped: [], absent: 0 };
+ for (const { path: rel, sha256 } of removed) {
+ const bad = removalProblem(rel);
+ if (bad) { out.skipped.push(`${rel} (${bad})`); continue; }
+ if (keep.has(rel.toLowerCase())) { out.skipped.push(`${rel} (a file of the new version)`); continue; }
+ const p = abs(base, rel);
+ let st;
+ try { st = fs.lstatSync(p); } catch { out.absent++; continue; }
+ if (!st.isFile()) { out.skipped.push(`${rel} (not a regular file)`); continue; }
+ let dir;
+ try { dir = fs.realpathSync(path.dirname(p)); } catch { dir = null; }
+ if (!dir || (dir !== real && !inside(real, dir))) { out.skipped.push(`${rel} (outside the install)`); continue; }
+ const got = digestFile(p);
+ if (!got || !sha256.includes(got.sha256)) { out.kept.push(rel); continue; }
+ try { fs.unlinkSync(p); } catch (e) { out.skipped.push(`${rel} (${e?.code || e?.message || e})`); continue; }
+ out.deleted.push(rel);
+ for (let d = path.dirname(p); inside(base, d); d = path.dirname(d)) {
+ try { fs.rmdirSync(d); } catch { break; }
+ }
+ }
+ return out;
+}
+
+/** The files data/local-assets.json lists that are not on disk (none when it is absent or unreadable). */
+export function localArtGaps(root) {
+ let listed;
+ try { listed = listedArtFiles(JSON.parse(fs.readFileSync(path.join(root, 'data', 'local-assets.json'), 'utf8'))); } catch { return []; }
+ return [...listed].filter((rel) => { try { return !fs.statSync(abs(root, rel)).isFile(); } catch { return true; } }).sort();
+}
+
+const NAMED = 5;
+const named = (list, sep) => list.slice(0, NAMED).join(sep) + (list.length > NAMED ? ' …' : '');
+
+/**
+ * Verify an install against its MANIFEST.json (npm run doctor; read-only).
+ * @returns {{ state: 'none'|'broken'|'ok'|'mismatch', pending: boolean, app?: string, error?: string, checked?: number,
+ * missing?: string[], mismatched?: string[], runtime?: string[], other?: string[] }}
+ * none: no MANIFEST.json (a source checkout); runtime / other: the bad paths that do / do not stop an update
+ */
+export function checkInstall(root) {
+ const base = path.resolve(root);
+ const pending = fs.existsSync(path.join(base, UPDATE_FILE));
+ let m;
+ try { m = parseManifest(fs.readFileSync(path.join(base, MANIFEST_FILE), 'utf8')); } catch (e) {
+ return e?.code === 'ENOENT' ? { state: 'none', pending } : { state: 'broken', pending, error: e?.message || String(e) };
+ }
+ const v = verifyFiles(base, m.files);
+ const bad = [...v.missing, ...v.mismatched].sort();
+ return {
+ state: bad.length ? 'mismatch' : 'ok', pending, app: m.app, checked: v.checked, missing: v.missing, mismatched: v.mismatched,
+ runtime: bad.filter(affectsRuntime), other: bad.filter((f) => !affectsRuntime(f)),
+ };
+}
+
+/**
+ * Finish an update package extracted over the install at `root` (the header's steps 1–4). Never throws.
+ * @param {string} root
+ * @param {{ log?: { log: Function, warn: Function, error: Function } }} [opts]
+ * @returns {{ state: 'none'|'malformed'|'failed'|'applied', [k: string]: any }} failed: the server must not start
+ */
+export function applyPendingUpdate(root, { log = console } = {}) {
+ try {
+ return applyUpdate(path.resolve(root), log);
+ } catch (e) {
+ // a bug here must not keep a server down: report it and start as before
+ log.error(`[update] 更新检查出错,已跳过 · the update check failed and was skipped: ${e?.stack || e}`);
+ return { state: 'malformed', error: e?.message || String(e) };
+ }
+}
+
+function applyUpdate(base, log) {
+ const file = path.join(base, UPDATE_FILE);
+ let text;
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
+ if (e?.code === 'ENOENT') return { state: 'none' };
+ log.warn(`[update] ${UPDATE_FILE} 无法读取,已跳过,服务器按现有文件启动(${e?.code || e?.message})· could not read ${UPDATE_FILE}: skipped, the server starts with the files as they are`);
+ return { state: 'malformed', error: String(e?.code || e?.message) };
+ }
+ let u;
+ try { u = parseUpdate(text); } catch (e) {
+ log.warn(`[update] ${UPDATE_FILE} 格式不对,已跳过,服务器按现有文件启动(${e.message})· ${UPDATE_FILE} is malformed: skipped, the server starts with the files as they are`);
+ return { state: 'malformed', error: e.message };
+ }
+ const app = `v${u.app}`;
+ const fromZh = u.from.map((v) => `v${v}`).join('、');
+ const fromEn = u.from.map((v) => `v${v}`).join(' / ');
+ log.log(`[update] 正在应用更新包 ${app}(适用于 ${fromZh}),校验文件… · applying the ${app} update package (for ${fromEn}): verifying the install…`);
+
+ let manifest = null;
+ let reason = null;
+ try { manifest = parseManifest(fs.readFileSync(path.join(base, MANIFEST_FILE), 'utf8')); } catch (e) {
+ reason = e?.code === 'ENOENT' ? `${MANIFEST_FILE} is missing` : e?.message || String(e);
+ }
+ if (manifest && manifest.app !== u.app) reason = `${MANIFEST_FILE} belongs to v${manifest.app}`;
+ if (reason) {
+ log.error(`[update] 更新包 ${app} 没有完整解压(${reason})。服务器没有启动:请把更新包重新完整解压到安装文件夹,或下载 ${app} 的完整包。`);
+ log.error(`[update] The ${app} update package is incomplete (${reason}). The server did not start: extract the whole update package over the install again, or download the full ${app} package.`);
+ return { state: 'failed', reason };
+ }
+
+ // the manifest (every non-art file of the new version) and the art files the update brought
+ const want = new Map(manifest.files);
+ for (const [rel, d] of u.files) if (!want.has(rel) && isSetupArt(rel)) want.set(rel, d);
+ const v = verifyFiles(base, want);
+ const bad = [...v.missing, ...v.mismatched].sort();
+ const fatal = bad.filter((f) => !isSetupArt(f) && affectsRuntime(f));
+ const notes = bad.filter((f) => !isSetupArt(f) && !affectsRuntime(f));
+ const art = bad.filter(isSetupArt);
+ if (fatal.length) {
+ log.error(`[update] 这个更新包只能覆盖在 ${fromZh} 的整合包安装上(检测到 ${fatal.length} 个文件与 ${app} 不一致或缺失,例:${named(fatal, '、')})。服务器没有启动:请下载 ${app} 的完整包重新安装,或确认更新包已完整解压。`);
+ log.error(`[update] This update package only applies over a ${fromEn} install (${fatal.length} files differ from ${app} or are missing, e.g. ${named(fatal, ', ')}). The server did not start: download the full ${app} package, or extract the update package again.`);
+ return { state: 'failed', checked: v.checked, missing: v.missing, mismatched: v.mismatched, fatal, notes, art };
+ }
+
+ const keep = new Set([...want.keys()].map((f) => f.toLowerCase()));
+ const removal = deleteRemoved(base, u.removed, keep);
+ let renamed = true;
+ try { fs.renameSync(file, path.join(base, APPLIED_FILE)); } catch (e) {
+ renamed = false;
+ log.warn(`[update] ${UPDATE_FILE} 无法改名为 ${APPLIED_FILE}(${e?.code || e?.message}),下次启动会再检查一遍 · could not rename ${UPDATE_FILE}: the next start checks again`);
+ }
+ log.log(`[update] 已更新到 ${app}:${v.checked} 个文件校验通过,删除 ${removal.deleted.length} 个旧文件 · updated to ${app}: ${v.checked} files verified, ${removal.deleted.length} old files deleted`);
+ if (notes.length) log.warn(`[update] ${notes.length} 个说明 / 脚本文件与 ${app} 不一致(不影响运行,例:${named(notes, '、')})· ${notes.length} doc / script files differ from ${app} (the server is not affected)`);
+ if (art.length) log.warn(`[update] ${art.length} 个素材文件没有完整解压(例:${named(art, '、')}),画面会缺图:重新解压更新包即可 · ${art.length} art files of the update are missing or damaged: extract the update package again`);
+ if (removal.kept.length) log.warn(`[update] 保留了 ${removal.kept.length} 个在这台电脑上改过的旧文件(新版本已不再使用,可以手动删除):${named(removal.kept, '、')} · kept ${removal.kept.length} old files changed on this machine (no longer used)`);
+ if (removal.skipped.length) log.warn(`[update] ${removal.skipped.length} 个旧文件没有删除:${named(removal.skipped, '、')} · ${removal.skipped.length} old files were not deleted`);
+ const gaps = u.files.has('data/local-assets.json') ? localArtGaps(base) : [];
+ if (gaps.length) {
+ log.warn(`[update] 本地客户端素材不完整:data/local-assets.json 列出的 ${gaps.length} 个文件不在这台电脑上(这个安装原来没有本地素材?例:${named(gaps, '、')})。从 ${app} 的完整包复制 public/assets/local 和 data/local-assets.json,或运行 node tools/setup.mjs --local 重新提取;不需要时删除 data/local-assets.json 即可 · the local-client art is incomplete (${gaps.length} files data/local-assets.json lists are missing): copy public/assets/local and data/local-assets.json from the full ${app} package, re-extract with node tools/setup.mjs --local, or delete data/local-assets.json`);
+ }
+ return { state: 'applied', renamed, checked: v.checked, notes, art, localGaps: gaps, ...removal };
+}
diff --git a/test/update-boot.test.js b/test/update-boot.test.js
new file mode 100644
index 00000000..8df87270
--- /dev/null
+++ b/test/update-boot.test.js
@@ -0,0 +1,336 @@
+// test/update-boot.test.js — the update package on the player's machine (server/update.js, docs/DEPLOY.md §1.5): the
+// paths UPDATE.json may name, the boot step that finishes an update (deletes, renames, refuses a mismatched install,
+// skips a malformed file), and the MANIFEST.json check `npm run doctor` shows. Temporary folders only.
+// Run: node --test test/update-boot.test.js (the packaging side: test/update-package.test.js)
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {
+ APPLIED_FILE, MANIFEST_FILE, UPDATE_FILE, affectsRuntime, applyPendingUpdate, checkInstall, deleteRemoved, digestFile,
+ inManifest, isSetupArt, parseManifest, parseUpdate, pathProblem, removalProblem, verifyFiles,
+} from '../server/update.js';
+
+const sha = (body) => crypto.createHash('sha256').update(body).digest('hex');
+const dg = (body) => ({ size: Buffer.byteLength(body), sha256: sha(body) });
+const tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `sp-update-${tag}-`));
+const put = (dir, rel, body = `// ${rel}\n`) => {
+ fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
+ fs.writeFileSync(path.join(dir, rel), body);
+};
+const has = (dir, rel) => fs.existsSync(path.join(dir, rel));
+const read = (dir, rel) => fs.readFileSync(path.join(dir, rel), 'utf8');
+const quiet = () => {
+ const lines = [];
+ return { lines, log: { log: (m) => lines.push(['log', m]), warn: (m) => lines.push(['warn', m]), error: (m) => lines.push(['error', m]) } };
+};
+/** MANIFEST.json / UPDATE.json as tools/package-update.mjs writes them (the same keys; its line layout is not needed here). */
+const manifestText = ({ app, files }) => JSON.stringify({ format: 1, app, files: Object.fromEntries(files) });
+const updateText = ({ app, from, files, removed }) => JSON.stringify({ format: 1, app, from, count: files.size,
+ bytes: [...files.values()].reduce((n, d) => n + d.size, 0), files: Object.fromEntries(files), removed });
+
+// ---------------------------------------------------------------------------------------------------------------------
+// The paths an update may name
+
+test('refusal rules: traversal, absolute and drive paths, backslashes, per-machine folders and the update files are never removed', () => {
+ for (const p of ['../x', 'a/../../x', 'server/../../etc/passwd', 'a/./b', 'a//b', 'a/', '', '..', '.']) assert.ok(pathProblem(p), `traversal: ${JSON.stringify(p)}`);
+ for (const p of ['/etc/passwd', 'C:/Windows/x', 'C:\\Windows\\x', 'c:x', '\\\\server\\share\\x', 'server\\index.js', 'a\0b', 'a/b.', 'a/b ']) {
+ assert.ok(pathProblem(p), `absolute / not plain: ${JSON.stringify(p)}`);
+ }
+ for (const p of [null, undefined, 3, {}]) assert.ok(pathProblem(p) && removalProblem(p), String(p));
+ for (const p of ['.git/config', '.cache/assets-report.json', 'logs/server.log', '.venv-extract/bin/python', 'tools/local-extract/.venv/x',
+ 'data/.env', '.env.local', 'scripts/service.env.cmd', 'node_modules/.cache/x', 'handoff/HANDOFF.md', '.claude/settings.json',
+ MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) {
+ assert.ok(removalProblem(p), `never removed: ${p}`);
+ assert.equal(pathProblem(p), null, `${p} may still be listed (a manifest path is only read)`);
+ }
+ for (const p of ['../x', '/etc/passwd', 'C:/x']) assert.ok(removalProblem(p), p);
+ for (const p of ['server/old.js', 'packs/qab/pack.json', 'node_modules/three/build/three.cjs', 'public/assets/ui/b c.png', 'data/i18n/zh-TW.json',
+ 'docs/research/07-assets.json', 'README.md']) {
+ assert.equal(removalProblem(p), null, p);
+ }
+ // a dependency's own .env.example can be verified (MANIFEST.json) but an update never deletes it
+ const m = parseManifest(JSON.stringify({ format: 1, app: '0.2.1', files: { 'node_modules/x/.env.example': dg('x') } }));
+ assert.deepEqual([...m.files.keys()], ['node_modules/x/.env.example']);
+});
+
+const H = sha('x');
+const goodUpdate = (patch = {}) => JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 1, bytes: 1,
+ files: { 'server/a.js': { size: 1, sha256: H } }, removed: [{ path: 'server/old.js', sha256: [H] }], ...patch });
+
+test('UPDATE.json is read strictly: one bad entry and the whole file is refused', () => {
+ const u = parseUpdate(goodUpdate());
+ assert.equal(u.app, '0.2.1');
+ assert.deepEqual(u.removed, [{ path: 'server/old.js', sha256: [H] }]);
+ assert.deepEqual([...u.files.keys()], ['server/a.js']);
+ const bad = {
+ 'not JSON': '{"format": 1,',
+ 'not an object': '[]',
+ 'another format': goodUpdate({ format: 2 }),
+ 'no version': goodUpdate({ app: 'latest' }),
+ 'no base': goodUpdate({ from: [] }),
+ 'no count': goodUpdate({ count: -1 }),
+ 'files not an object': goodUpdate({ files: [] }),
+ 'a file without a hash': goodUpdate({ files: { 'server/a.js': { size: 1 } } }),
+ 'a file outside the install': goodUpdate({ files: { '../a.js': { size: 1, sha256: H } } }),
+ 'removed not a list': goodUpdate({ removed: {} }),
+ 'traversal': goodUpdate({ removed: [{ path: '../../outside.txt', sha256: [H] }] }),
+ 'absolute': goodUpdate({ removed: [{ path: '/etc/hosts', sha256: [H] }] }),
+ 'a user folder': goodUpdate({ removed: [{ path: 'logs/server.log', sha256: [H] }] }),
+ 'twice': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [H] }, { path: 'server/old.js', sha256: [H] }] }),
+ 'shipped and removed': goodUpdate({ removed: [{ path: 'server/a.js', sha256: [H] }] }),
+ 'no hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [] }] }),
+ 'a bad hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: ['abc'] }] }),
+ 'a bare path': goodUpdate({ removed: ['server/old.js'] }),
+ };
+ for (const [what, text] of Object.entries(bad)) assert.throws(() => parseUpdate(text), undefined, what);
+});
+
+// ---------------------------------------------------------------------------------------------------------------------
+// The boot step
+
+/** An install of the new version: `files` on disk, MANIFEST.json over the non-art ones, UPDATE.json as given. */
+function newInstall(onDisk, { app = '0.2.1', from = ['0.2.0'], shipped = [], removed = [], manifest = null } = {}) {
+ const dir = tmp('inst');
+ for (const [rel, body] of Object.entries(onDisk)) put(dir, rel, body);
+ const listed = new Map();
+ for (const rel of manifest ?? Object.keys(onDisk).filter(inManifest)) listed.set(rel, digestFile(path.join(dir, rel)));
+ fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app, files: listed }));
+ const ship = new Map(shipped.map((rel) => [rel, digestFile(path.join(dir, rel))]));
+ fs.writeFileSync(path.join(dir, UPDATE_FILE), updateText({ app, from, files: ship, removed }));
+ return dir;
+}
+
+test('boot apply: deletes the removed files that hold a base\'s bytes, keeps changed ones and players\' packs, renames UPDATE.json, then does nothing', () => {
+ const dir = newInstall({
+ 'server/index.js': 'new', 'shared/constants.js': "APP_VERSION='0.2.1'", 'README.md': 'readme',
+ // left over from 0.2.0
+ 'server/old.js': 'old', 'server/lib/only/gone.js': 'gone', 'data/old.json': 'edited here', 'packs/qab/pack.json': 'shipped pack',
+ 'packs/mine/pack.json': 'a player\'s own pack',
+ }, {
+ shipped: ['server/index.js', 'shared/constants.js'],
+ manifest: ['server/index.js', 'shared/constants.js', 'README.md'],
+ removed: [
+ { path: 'server/old.js', sha256: [sha('old')] },
+ { path: 'server/lib/only/gone.js', sha256: [sha('gone')] },
+ { path: 'data/old.json', sha256: [sha('as shipped')] },
+ { path: 'packs/qab/pack.json', sha256: [sha('shipped pack')] },
+ { path: 'packs/mine/pack.json', sha256: [sha('the 0.2.0 pack of that name')] },
+ { path: 'server/never-here.js', sha256: [sha('x')] },
+ ],
+ });
+ try {
+ const { lines, log } = quiet();
+ const r = applyPendingUpdate(dir, { log });
+ assert.equal(r.state, 'applied', JSON.stringify(lines));
+ assert.deepEqual(r.deleted.sort(), ['packs/qab/pack.json', 'server/lib/only/gone.js', 'server/old.js']);
+ assert.deepEqual(r.kept.sort(), ['data/old.json', 'packs/mine/pack.json'], 'other bytes than any base shipped: left alone');
+ assert.equal(r.absent, 1);
+ assert.ok(!has(dir, 'server/old.js') && !has(dir, 'server/lib'), 'the folders it leaves empty go too');
+ assert.ok(has(dir, 'server/index.js') && has(dir, 'packs/mine/pack.json') && has(dir, 'data/old.json'));
+ assert.ok(!has(dir, 'packs/qab') && has(dir, 'packs'), 'an emptied pack folder goes, the packs folder with another pack stays');
+ assert.ok(!has(dir, UPDATE_FILE) && has(dir, APPLIED_FILE), 'UPDATE.json → .update-applied.json');
+ assert.equal(parseUpdate(read(dir, APPLIED_FILE)).app, '0.2.1');
+ assert.match(lines.map((l) => l[1]).join('\n'), /已更新到 v0\.2\.1:3 个文件校验通过,删除 3 个旧文件 · updated to v0\.2\.1/);
+ assert.ok(lines.some(([k, m]) => k === 'warn' && /保留了 2 个/.test(m)), 'the kept files are named');
+ // idempotent: nothing pending, nothing changes
+ const before = fs.readdirSync(dir, { recursive: true }).sort();
+ assert.equal(applyPendingUpdate(dir, { log }).state, 'none');
+ assert.deepEqual(fs.readdirSync(dir, { recursive: true }).sort(), before);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('boot apply: a malformed UPDATE.json is logged and skipped — nothing deleted, the file kept, never a throw', () => {
+ const outside = tmp('outside');
+ put(outside, 'victim.txt', 'keep me');
+ const dir = newInstall({ 'server/index.js': 'new', 'server/old.js': 'old' });
+ try {
+ const cases = [
+ '{"format": 1, "app": "0.2.1"', // truncated
+ JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {},
+ removed: [{ path: 'server/old.js', sha256: [sha('old')] }, { path: `../${path.basename(outside)}/victim.txt`, sha256: [sha('keep me')] }] }),
+ JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {}, removed: [{ path: path.join(outside, 'victim.txt'), sha256: [sha('keep me')] }] }),
+ JSON.stringify({ format: 99 }),
+ 'null',
+ ];
+ for (const text of cases) {
+ fs.writeFileSync(path.join(dir, UPDATE_FILE), text);
+ const { lines, log } = quiet();
+ const r = applyPendingUpdate(dir, { log });
+ assert.equal(r.state, 'malformed', text);
+ assert.ok(has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE), 'left in place');
+ assert.ok(has(dir, 'server/old.js'), 'one bad entry: not even the good ones are deleted');
+ assert.equal(read(outside, 'victim.txt'), 'keep me');
+ assert.match(lines[0][1], /UPDATE\.json 格式不对,已跳过,服务器按现有文件启动/);
+ }
+ fs.rmSync(path.join(dir, UPDATE_FILE));
+ fs.mkdirSync(path.join(dir, UPDATE_FILE)); // unreadable (a folder)
+ assert.equal(applyPendingUpdate(dir, quiet()).state, 'malformed');
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ fs.rmSync(outside, { recursive: true, force: true });
+ }
+});
+
+test('boot apply: a file the server runs that does not match MANIFEST.json stops the start; nothing is deleted, UPDATE.json stays', () => {
+ const dir = newInstall({ 'server/index.js': 'new', 'data/chess.json': '{}', 'server/old.js': 'old' }, {
+ manifest: ['server/index.js', 'data/chess.json'],
+ removed: [{ path: 'server/old.js', sha256: [sha('old')] }],
+ });
+ try {
+ put(dir, 'data/chess.json', '{"old": 1}'); // the base's copy: the update was extracted over another version
+ fs.rmSync(path.join(dir, 'server/index.js'));
+ const { lines, log } = quiet();
+ const r = applyPendingUpdate(dir, { log });
+ assert.equal(r.state, 'failed');
+ assert.deepEqual(r.fatal, ['data/chess.json', 'server/index.js']);
+ assert.ok(has(dir, 'server/old.js') && has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE));
+ const text = lines.filter(([k]) => k === 'error').map((l) => l[1]).join('\n');
+ assert.match(text, /这个更新包只能覆盖在 v0\.2\.0 的整合包安装上(检测到 2 个文件与 v0\.2\.1 不一致或缺失,例:data\/chess\.json、server\/index\.js)。服务器没有启动:请下载 v0\.2\.1 的完整包/);
+ assert.match(text, /This update package only applies over a v0\.2\.0 install \(2 files differ from v0\.2\.1 or are missing/);
+ // fixed (the right files extracted): the next start applies it
+ put(dir, 'data/chess.json', '{}');
+ put(dir, 'server/index.js', 'new');
+ assert.equal(applyPendingUpdate(dir, quiet()).state, 'applied');
+ assert.ok(!has(dir, 'server/old.js'));
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('boot apply: a missing or foreign MANIFEST.json stops the start; docs-only differences and missing art are warnings', () => {
+ const a = newInstall({ 'server/index.js': 'new' });
+ const b = newInstall({ 'server/index.js': 'new' });
+ const c = newInstall({ 'server/index.js': 'new', 'README.md': 'readme', 'scripts/start.sh': 'sh', 'node_modules/ws/README.md': 'r', 'public/assets/x.png': 'x' },
+ { shipped: ['public/assets/x.png'] });
+ try {
+ fs.rmSync(path.join(a, MANIFEST_FILE));
+ let q = quiet();
+ assert.equal(applyPendingUpdate(a, { log: q.log }).state, 'failed');
+ assert.match(q.lines.map((l) => l[1]).join('\n'), /没有完整解压(MANIFEST\.json is missing)/);
+ fs.writeFileSync(path.join(b, MANIFEST_FILE), manifestText({ app: '0.2.2', files: new Map() }));
+ q = quiet();
+ assert.equal(applyPendingUpdate(b, { log: q.log }).state, 'failed');
+ assert.match(q.lines.map((l) => l[1]).join('\n'), /MANIFEST\.json belongs to v0\.2\.2/);
+ put(c, 'README.md', 'edited');
+ put(c, 'scripts/start.sh', 'edited');
+ put(c, 'node_modules/ws/README.md', 'edited');
+ fs.rmSync(path.join(c, 'public/assets/x.png'));
+ q = quiet();
+ const r = applyPendingUpdate(c, { log: q.log });
+ assert.equal(r.state, 'applied');
+ assert.deepEqual(r.notes, ['README.md', 'node_modules/ws/README.md', 'scripts/start.sh']);
+ assert.deepEqual(r.art, ['public/assets/x.png']);
+ const warned = q.lines.filter(([k]) => k === 'warn').map((l) => l[1]).join('\n');
+ assert.match(warned, /3 个说明 \/ 脚本文件与 v0\.2\.1 不一致(不影响运行/);
+ assert.match(warned, /1 个素材文件没有完整解压/);
+ } finally {
+ for (const d of [a, b, c]) fs.rmSync(d, { recursive: true, force: true });
+ }
+});
+
+test('boot apply: a local-art manifest the update brought to an install without that art is named (a lite install)', () => {
+ const local = JSON.stringify({ groups: { g: { x: { path: '/assets/local/g/x.webp' }, y: { path: '/assets/local/g/y.webp' } } } });
+ const dir = newInstall({ 'server/index.js': 'new', 'data/local-assets.json': local, 'public/assets/local/g/y.webp': 'y' },
+ { shipped: ['data/local-assets.json', 'public/assets/local/g/y.webp'] });
+ try {
+ const q = quiet();
+ const r = applyPendingUpdate(dir, { log: q.log });
+ assert.equal(r.state, 'applied');
+ assert.deepEqual(r.localGaps, ['public/assets/local/g/x.webp']);
+ assert.match(q.lines.map((l) => l[1]).join('\n'), /本地客户端素材不完整:data\/local-assets\.json 列出的 1 个文件不在这台电脑上/);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('deleting removed files: never through a symlink, never a folder, never a file of the new version that differs only in case', () => {
+ const dir = tmp('del');
+ const outside = tmp('outside');
+ try {
+ put(outside, 'x.js', 'old');
+ put(dir, 'server/dir.js/inner.js', 'old');
+ put(dir, 'public/assets/X.png', 'png');
+ let links = true;
+ try {
+ fs.symlinkSync(path.join(outside, 'x.js'), path.join(dir, 'server', 'link.js'));
+ fs.symlinkSync(outside, path.join(dir, 'linked'), 'dir');
+ } catch { links = false; } // Windows without the symlink privilege
+ const r = deleteRemoved(dir, [
+ { path: 'server/link.js', sha256: [sha('old')] },
+ { path: 'linked/x.js', sha256: [sha('old')] },
+ { path: 'server/dir.js', sha256: [sha('old')] },
+ { path: 'public/assets/X.png', sha256: [sha('png')] },
+ { path: '../escape.js', sha256: [sha('old')] },
+ ], new Set(['public/assets/x.png']));
+ assert.deepEqual(r.deleted, []);
+ assert.equal(read(outside, 'x.js'), 'old', 'the symlink targets are untouched');
+ assert.ok(has(dir, 'public/assets/X.png') && has(dir, 'server/dir.js/inner.js'));
+ const why = r.skipped.join('\n');
+ if (links) {
+ assert.match(why, /server\/link\.js \(not a regular file\)/);
+ assert.match(why, /linked\/x\.js \(outside the install\)/);
+ }
+ assert.match(why, /server\/dir\.js \(not a regular file\)/);
+ assert.match(why, /public\/assets\/X\.png \(a file of the new version\)/);
+ assert.match(why, /\.\.\/escape\.js/);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ fs.rmSync(outside, { recursive: true, force: true });
+ }
+});
+
+// ---------------------------------------------------------------------------------------------------------------------
+// The manifest check
+
+test('manifest verification: missing files, other sizes, same size other bytes, a folder in place of a file; doctor\'s states', () => {
+ const dir = tmp('verify');
+ try {
+ put(dir, 'a.js', 'aaaa');
+ put(dir, 'b.js', 'bbbb');
+ put(dir, 'c.js', 'cccc');
+ fs.mkdirSync(path.join(dir, 'd.js'));
+ const v = verifyFiles(dir, new Map([['a.js', dg('aaaa')], ['b.js', dg('bbbbb')], ['c.js', dg('CCCC')], ['d.js', dg('d')], ['e.js', dg('e')]]));
+ assert.deepEqual(v, { checked: 5, missing: ['d.js', 'e.js'], mismatched: ['b.js', 'c.js'] });
+
+ assert.deepEqual(checkInstall(dir), { state: 'none', pending: false }, 'no MANIFEST.json: a source checkout');
+ fs.writeFileSync(path.join(dir, MANIFEST_FILE), '{');
+ assert.equal(checkInstall(dir).state, 'broken');
+ const listed = new Map([['a.js', dg('aaaa')], ['README.md', dg('readme')]]);
+ put(dir, 'README.md', 'readme');
+ fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app: '0.2.1', files: listed }));
+ assert.deepEqual(checkInstall(dir), { state: 'ok', pending: false, app: '0.2.1', checked: 2, missing: [], mismatched: [], runtime: [], other: [] });
+ put(dir, 'README.md', 'edited');
+ put(dir, 'a.js', 'AAAA');
+ put(dir, UPDATE_FILE, '{}');
+ const c = checkInstall(dir);
+ assert.equal(c.state, 'mismatch');
+ assert.deepEqual([c.runtime, c.other, c.pending], [['a.js'], ['README.md'], true]);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('what MANIFEST.json lists and what stops an update', () => {
+ for (const f of ['public/assets/char/a.png', 'public/fonts/fonts.css', 'data/assets.json', 'data/local-assets.json']) {
+ assert.ok(isSetupArt(f) && !inManifest(f), f);
+ }
+ for (const f of [MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) assert.ok(!inManifest(f), f);
+ for (const f of ['server/index.js', 'data/chess.json', 'node_modules/ws/index.js', 'public/vendor/pixi.min.js', 'README.md', 'packs/index.json']) {
+ assert.ok(inManifest(f), f);
+ }
+ for (const f of ['server/index.js', 'shared/constants.js', 'data/chess.json', 'public/js/main.js', 'public/vendor/pixi.min.js', 'packs/index.json',
+ 'node_modules/ws/package.json', 'node_modules/ws/lib/websocket.js', 'node_modules/x/license.js', 'docs/research/03-operators.json', 'package.json']) {
+ assert.ok(affectsRuntime(f), `${f} stops an update`);
+ }
+ for (const f of ['README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'docs/DEPLOY.md', 'node_modules/ws/README.md', 'node_modules/three/LICENSE',
+ 'node_modules/preact/src/index.d.ts', 'node_modules/x/dist/x.js.map', 'scripts/start-windows.bat', 'tools/setup.mjs',
+ 'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'package-lock.json', 'node_modules/.package-lock.json']) {
+ assert.ok(!affectsRuntime(f), `${f} only warns`);
+ }
+});
diff --git a/tools/doctor.mjs b/tools/doctor.mjs
index 7a602f6b..7c8aa45f 100755
--- a/tools/doctor.mjs
+++ b/tools/doctor.mjs
@@ -3,7 +3,9 @@
//
// node tools/doctor.mjs [--port 3000] [--host 0.0.0.0]
//
-// Checks: Node/npm versions, dependencies, public/vendor, data/*.json, downloaded art/audio, optional local-client art,
+// Checks: Node/npm versions, dependencies, public/vendor, data/*.json, the shipped files against the release's
+// MANIFEST.json (server/update.js checkInstall; a source checkout has none) and an update package not applied yet,
+// downloaded art/audio, optional local-client art,
// Python (only needed for the optional extraction), the port (free / our server running → /healthz / another
// program), LAN addresses friends can use (virtual adapters and VPNs labelled), firewall hints per OS, tunnel tools
// (Tailscale, ZeroTier, cloudflared) and the env vars the server reads.
@@ -20,6 +22,7 @@ import {
checkNode, checkDeps, checkVendor, checkData, checkAssets, checkLocal, findClient, findPython,
LOCAL_ART_FALLBACK, LOCAL_ART_COPY_HINT,
} from './setup.mjs';
+import { checkInstall, MANIFEST_FILE, UPDATE_FILE } from '../server/update.js';
// ---------------------------------------------------------------------------------------------------
// LAN addresses (also used by scripts/launch.mjs)
@@ -184,6 +187,19 @@ async function main() {
row(vendor.ok ? 'ok' : 'err', '前端库 public/vendor', vendor.ok ? (vendor.optionalMissing.length ? 'three.js 缺失(3D 棋盘回退 2D)' : '') : `缺少 ${vendor.missing.join(', ')} → node tools/vendor.mjs`);
const data = checkData();
row(data.ok ? 'ok' : 'err', '游戏数据 data/*.json', data.ok ? '' : `缺少/损坏:${[...data.missing, ...data.broken].join(', ')}`);
+ // the release's file list (every package has one; art is setup's and is checked below)
+ const inst = checkInstall(ROOT);
+ const instLabel = `文件校验 ${MANIFEST_FILE}`;
+ if (inst.state === 'none') row('skip', instLabel, '没有:源码目录,不校验');
+ else if (inst.state === 'broken') row('warn', instLabel, `无法读取(${inst.error})→ 重新解压同一版本的整合包`);
+ else if (inst.state === 'ok') row('ok', instLabel, `${inst.checked} 个文件与 v${inst.app} 一致`);
+ else {
+ const bad = [...inst.runtime, ...inst.other];
+ const eg = bad.slice(0, 3).join(' ') + (bad.length > 3 ? ' …' : '');
+ row(inst.runtime.length ? 'err' : 'warn', instLabel, `${bad.length} 个文件与 v${inst.app} 不一致或缺失(例:${eg})`
+ + (inst.runtime.length ? ` → 重新解压 v${inst.app} 的完整包(或它的更新包)` : ':只是说明 / 脚本文件,不影响运行'));
+ }
+ if (inst.pending) row('warn', `更新包 ${UPDATE_FILE}`, '已解压、还没有应用:下次启动服务器时自动应用');
const assets = checkAssets();
row(assets.ok ? 'ok' : 'warn', '美术/音频 public/assets', assets.ok ? `${assets.total} 个文件`
: !assets.present ? '未下载 → node tools/setup.mjs(游戏仍可运行,使用占位图)' : `缺 ${assets.missing}/${assets.total}(例:${assets.sample.join(' ')})→ node tools/setup.mjs`);