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

+26
View File
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { designText, DESIGN_DIRS } from './helpers/designDocs.js';
import { PLAYER_MARKDOWN, rewritePlayerDoc, selectTracked, trackedFiles } from '../tools/package.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Each document with the least number of paths it names (a guard against an extraction that finds nothing). */
@@ -141,3 +142,28 @@ test('DESIGN: every "DESIGN §N" cited in the repository (code comments, tests,
assert.ok(cited >= 700, `only ${cited} citations found — is the scan broken?`);
assert.deepEqual(bad, [], 'citations of a section or subsection with no heading');
});
test('shipped markdown: a source link exists or is releases/latest, and the packaged copy only links to shipped files', () => {
const shipped = new Set(selectTracked(trackedFiles(ROOT)).keep);
const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
assert.ok(PLAYER_MARKDOWN.length >= 6, PLAYER_MARKDOWN.join(' '));
for (const rel of PLAYER_MARKDOWN) {
const md = readFileSync(join(ROOT, rel), 'utf8');
for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
const href = m[1];
if (/^(?:[a-z]+:|#)/i.test(href)) continue;
const raw = href.replace(/#.*$/, '');
const norm = normalize(join(dirname(rel), raw)).split('\\').join('/');
if (norm.startsWith('..')) assert.match(norm, /^(?:\.\.\/)+releases\/latest$/, `${rel} → ${href}`);
else assert.ok(existsSync(join(ROOT, norm)), `${rel} → ${href}`);
}
const next = rewritePlayerDoc(md, rel, shipped, version);
for (const m of next.matchAll(/\]\(([^)\s]+)\)/g)) {
const href = m[1];
if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href)) continue;
const raw = href.replace(/#.*$/, '');
const norm = normalize(join(dirname(rel), raw)).split('\\').join('/');
assert.ok(shipped.has(norm), `${rel} packaged link ${href} (${norm}) is not in the zip`);
}
}
});
+106
View File
@@ -0,0 +1,106 @@
// GitHub #531 / #530: the player zip must not ship a package.json whose `npm test` is a silent green, and the
// Markdown it ships must not keep relative links to files the zip does not contain. The repository copies stay as they
// are; tools/package.mjs rewrites the staged copies. These tests call that rewrite directly (no zip, no browser).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PLAYER_MARKDOWN, PLAYER_NPM_SCRIPTS, playerPackageJson, playerPackageProblems, releaseTag, rewritePlayerDoc,
selectTracked, trackedFiles,
} from '../tools/package.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
const DISHONEST = ['test', 'golden', 'golden:update', 'build-data', 'check:imports', 'ci', 'package', 'package:lite', 'lint', 'typecheck'];
const KEPT = [...PLAYER_NPM_SCRIPTS, 'dev'];
test('release tags drop a trailing -dev and keep an already released version', () => {
assert.equal(releaseTag('0.2.5-dev'), 'v0.2.5');
assert.equal(releaseTag('0.2.4'), 'v0.2.4');
assert.equal(releaseTag('9.9.9'), 'v9.9.9');
});
test('the packaged package.json keeps only scripts whose node files ship', () => {
const shipped = selectTracked(trackedFiles(ROOT)).keep;
const next = playerPackageJson(pkg, shipped);
for (const name of DISHONEST) assert.equal(next.scripts[name], undefined, name);
for (const name of KEPT) assert.equal(next.scripts[name], pkg.scripts[name], name);
assert.deepEqual(Object.keys(next), Object.keys(pkg));
assert.deepEqual(playerPackageProblems(pkg, shipped), []);
assert.equal(pkg.scripts.test, 'node --test', 'the repository package.json still runs the suite');
assert.equal(pkg.scripts.lint, 'eslint .');
const keys = Object.keys(next);
assert.deepEqual(keys.filter((k) => k !== 'scripts'), Object.keys(pkg).filter((k) => k !== 'scripts'));
});
test('a script with no shipped node file is dropped, including a bare node --test', () => {
const shipped = new Set(['server/index.js', 'tools/vendor.mjs']);
const next = playerPackageJson({
name: 'x', version: '1.0.0',
scripts: {
start: 'node server/index.js',
dev: 'node --watch server/index.js',
test: 'node --test',
named: 'node --test test/a.test.js',
lint: 'eslint .',
both: 'node tools/vendor.mjs && node tools/missing.mjs',
vendor: 'node tools/vendor.mjs',
},
}, shipped);
assert.deepEqual(Object.keys(next.scripts).sort(), ['dev', 'start', 'vendor']);
assert.ok(playerPackageProblems({ scripts: { start: 'node server/missing.js', setup: 'node tools/setup.mjs', doctor: 'node tools/doctor.mjs', launch: 'node scripts/launch.mjs', postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs' } }, shipped).length > 0);
});
function linkTarget(from, href) {
const raw = href.split('#')[0];
const dir = path.posix.dirname(from);
return path.posix.normalize(path.posix.join(dir, raw));
}
test('packaged copies of the shipped docs point unshipped relative links at the release tag', () => {
const shipped = new Set(selectTracked(trackedFiles(ROOT)).keep);
const version = pkg.version;
const tag = releaseTag(version);
const host = 'https://github.com/sganggs/Stronghold-Protocol';
const focus = new Set(['README.md', 'docs/PLAYING.md', 'docs/DEPLOY.md']);
let fileRewrites = 0;
assert.ok(PLAYER_MARKDOWN.includes('README.md') && PLAYER_MARKDOWN.includes('docs/PLAYING.md') && PLAYER_MARKDOWN.includes('docs/DEPLOY.md'));
for (const rel of PLAYER_MARKDOWN) {
const md = readFileSync(path.join(ROOT, rel), 'utf8');
for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
const href = m[1];
if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href)) continue;
const target = linkTarget(rel, href);
const onDisk = target.startsWith('..') ? null : path.join(ROOT, target);
const releaseLatest = /^(?:\.\.\/)+releases\/latest$/.test(target);
assert.ok(releaseLatest || (onDisk && existsSync(onDisk)), `${rel} source link ${href} (${target})`);
}
const next = rewritePlayerDoc(md, rel, shipped, version);
assert.equal(rewritePlayerDoc(next, rel, shipped, version), next, `${rel} rewrite is idempotent`);
const before = [...md.matchAll(/(!?\[[^\]]*\]\()([^)\s]+)\)/g)];
const after = [...next.matchAll(/(!?\[[^\]]*\]\()([^)\s]+)\)/g)];
assert.equal(before.length, after.length, rel);
for (let i = 0; i < after.length; i++) {
const href = after[i][2];
if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href)) {
if (before[i][2] !== href) assert.ok(href.startsWith(`${host}/`), `${rel} ${before[i][2]} → ${href}`);
continue;
}
const target = linkTarget(rel, href);
assert.ok(!target.startsWith('..') && shipped.has(target), `${rel} still links to ${href} (${target}), which the zip does not ship`);
}
if (!focus.has(rel)) continue;
for (let i = 0; i < before.length; i++) {
if (before[i][2] === after[i][2]) continue;
if (/\.(?:png|jpe?g|gif|webp|svg)(?:#|$)/i.test(before[i][2])) {
assert.match(after[i][2], new RegExp(`^${host}/raw/${tag}/`));
} else {
fileRewrites++;
assert.match(after[i][2], new RegExp(`^${host}/(?:blob/${tag}/|releases/)`));
}
}
}
assert.ok(fileRewrites >= 23, `README / PLAYING / DEPLOY rewrote ${fileRewrites} file links, want at least 23`);
});
+36 -2
View File
@@ -11,8 +11,8 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
FOLDER, REFUSE, RUNTIME_RESEARCH, entryProblems, isRefused, packageOutIsUnsafe, plan, resolveSpecifier, scanFiles,
selectTracked,
FOLDER, REFUSE, RUNTIME_RESEARCH, entryProblems, isRefused, packageOutIsUnsafe, plan, playerPackageJson,
rewritePlayerDoc, resolveSpecifier, scanFiles, selectTracked,
} from '../tools/package.mjs';
// a fake home path, built at run time so the repository never contains one (the local push guard scans every patch)
@@ -311,6 +311,40 @@ test('content packs ship: every committed pack of both layouts, never one only o
}
});
test('the stage drops dishonest npm scripts and rewrites unshipped doc links', { skip: !hasZipTool && 'no zip / tar' }, () => {
const { dir, put } = fakeCheckout();
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-out-'));
const readme = '# r\n\n[design](docs/DESIGN.md)\n[play](docs/PLAYING.md)\n[rel](../../releases/latest)\n![shot](docs/img/x.jpg)\n';
try {
put('README.md', readme);
put('docs/PLAYING.md', '# p\n\n[idx](research/00-INDEX.md)\n[home](../README.md#操作)\n');
put('docs/DEPLOY.md', '# d\n\n[packs](PACKS.md)\n[win](WINDOWS.md)\n[notice](../NOTICE.md)\n');
const r = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty', '--lite', '--keep-stage']);
assert.equal(r.status, 0, r.stdout + r.stderr);
const stage = path.join(out, `${FOLDER}-v9.9.9-lite`, FOLDER);
const stagedPkg = JSON.parse(fs.readFileSync(path.join(stage, 'package.json'), 'utf8'));
assert.deepEqual(Object.keys(stagedPkg.scripts).sort(), ['assets', 'doctor', 'launch', 'postinstall', 'setup', 'start', 'vendor']);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).scripts.test, 'node --test');
const host = 'https://github.com/sganggs/Stronghold-Protocol';
const got = fs.readFileSync(path.join(stage, 'README.md'), 'utf8');
assert.equal(got, `# r\n\n[design](${host}/blob/v9.9.9/docs/DESIGN.md)\n[play](docs/PLAYING.md)\n[rel](${host}/releases/latest)\n![shot](${host}/raw/v9.9.9/docs/img/x.jpg)\n`);
assert.equal(fs.readFileSync(path.join(dir, 'README.md'), 'utf8'), readme, 'the git checkout keeps its relative links');
const playing = fs.readFileSync(path.join(stage, 'docs/PLAYING.md'), 'utf8');
assert.match(playing, new RegExp(`\\(${host}/blob/v9.9.9/docs/research/00-INDEX.md\\)`));
assert.match(playing, /\(\.\.\/README\.md#操作\)/);
const deploy = fs.readFileSync(path.join(stage, 'docs/DEPLOY.md'), 'utf8');
assert.match(deploy, new RegExp(`\\(${host}/blob/v9.9.9/docs/PACKS.md\\)`));
assert.match(deploy, new RegExp(`\\(${host}/blob/v9.9.9/docs/WINDOWS.md\\)`));
assert.match(deploy, /\(\.\.\/NOTICE\.md\)/);
const shipped = new Set(plan(dir, { lite: true, allowDirty: true, scan: false, measure: false }).tracked);
assert.equal(rewritePlayerDoc(readme, 'README.md', shipped, '9.9.9'), got);
assert.equal(playerPackageJson(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')), shipped).scripts.test, undefined);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(out, { recursive: true, force: true });
}
});
test('the personal scan reads bytes, never reports the matched text, and skips files it cannot read', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-'));
try {
+48
View File
@@ -0,0 +1,48 @@
// GitHub #538: a loadout file that carries `ops` (including `ops: {}`) replaces 潜能 / 练度. That list includes
// 自选 operators, which only backups.json names. Importing it while that file is missing used to drop them and still
// save, clearing a 练度 the file did not mean to erase. The whole import is refused first, with the 自选编队 data check.
// No browser: the refusal is a function, and ioApply must call it after a successful parse and before any write.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
globalThis.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
const warn = console.warn;
console.warn = (...a) => { if (!String(a[0]).startsWith('[data]')) warn(...a); };
const { loadoutOpsImportRefusal } = await import('../../public/js/screens/loadout.js');
const NOT_LOADED = '导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络';
const LOADING = '干员数据仍在载入,请稍候再导入';
const statusOf = (map, dflt = 'ready') => (name) => map[name] ?? dflt;
test('a loadout ops payload is refused with the DIY data check; entries-only is not', () => {
const noBackups = statusOf({ backups: 'missing' });
const bothMissing = statusOf({ chess: 'missing', backups: 'missing' });
const ready = statusOf({});
assert.equal(loadoutOpsImportRefusal(null, true, noBackups), null, 'no ops key: current 练度 stays');
assert.equal(loadoutOpsImportRefusal(undefined, true, noBackups), null);
assert.deepEqual(loadoutOpsImportRefusal({}, true, noBackups), { text: NOT_LOADED, tone: 'error' }, 'ops: {} still replaces 练度');
assert.deepEqual(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, noBackups), { text: NOT_LOADED, tone: 'error' });
assert.deepEqual(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, bothMissing), { text: NOT_LOADED, tone: 'error' });
assert.deepEqual(loadoutOpsImportRefusal({}, false, ready), { text: LOADING, tone: 'warn' }, 'still loading: nothing is written');
assert.equal(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, ready), null, 'both files ready: the import may proceed');
assert.equal(loadoutOpsImportRefusal(null, false, noBackups), null, 'entries-only does not wait on backups');
});
test('ioApply asks loadoutOpsImportRefusal after parseImport and before cultivationCharIds or applyLoadoutEntries', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/loadout.js'), 'utf8');
const at = src.indexOf('const ioApply = () => {');
assert.ok(at > 0, 'ioApply');
const body = src.slice(at, src.indexOf('\n };\n', at));
const parsed = body.indexOf('if (!res.ok)');
const guard = body.indexOf('loadoutOpsImportRefusal(res.ops, ready)');
const ids = body.indexOf('cultivationCharIds(');
const apply = body.indexOf('applyLoadoutEntries(');
assert.ok(parsed > 0 && guard > parsed, 'refuse only a payload that parsed');
assert.ok(ids > guard && apply > guard, 'no write and no DIY id list until the refusal returns null');
assert.match(body.slice(guard), /^loadoutOpsImportRefusal\(res\.ops, ready\);\s*if \(opsRefused\) \{ toast\(opsRefused\.text, opsRefused\.tone\); return; \}/);
});
+174
View File
@@ -0,0 +1,174 @@
// Awaiting game data must never let an obsolete loadout overwrite a later edit/reset.
// These are controlled readiness inputs; the real screen route is the same prepare/flush path.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createStore } from '../../public/js/store.js';
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js';
import { cultivationCharIds } from '../../shared/protocol.js';
const CHESS = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url)));
const BACKUPS = JSON.parse(readFileSync(new URL('../../data/backups.json', import.meta.url)));
const INSIDE = 'chess_char_1_01_a';
const CHAR = CHESS[INSIDE].charId;
const ENTRIES = { [INSIDE]: { skill: 0 } };
const OPS = { [CHAR]: { potential: 1 } };
const IDS = cultivationCharIds(CHESS, BACKUPS);
const ticks = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function waitingData() {
const started = deferred(), result = deferred();
return { started: started.promise, ...result, ready() { started.resolve(); return result.promise; } };
}
function harness({ chess = async () => CHESS, backups = async () => BACKUPS } = {}) {
const listeners = new Map(), pending = new Map();
let time = 0, timerId = 0;
const timers = {
setTimeout(fn, ms) { const id = ++timerId; pending.set(id, { fn, at: time + ms }); return id; },
clearTimeout(id) { pending.delete(id); },
async advance(ms) {
time += ms;
for (const [id, task] of [...pending]) if (task.at <= time) { pending.delete(id); task.fn(); await ticks(); }
await ticks();
},
};
const net = {
status: 'online', sent: [], failures: [],
on(name, fn) { listeners.set(name, fn); return () => listeners.delete(name); },
emit(name, msg = {}) { listeners.get(name)?.(msg); },
async request(t, fields) {
net.sent.push({ t, ...fields });
const code = net.failures.shift();
if (code) throw Object.assign(new Error(code), { code });
return { t: 'ok' };
},
};
const target = createStore({ entries: ENTRIES, ops: OPS, open: true, sync: 'idle' });
const sync = installLoadoutSync({ net, timers, target, getChessReady: chess, getBackupsReady: backups,
lookupChess: (id) => CHESS[id], operatorIds: () => IDS });
return { net, timers, target, ...sync };
}
const empty = { t: 'room.loadout', entries: {}, ops: {} };
test('sync: a reset supersedes the old loadout awaiting backups, even after the reset was accepted', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const old = h.flush();
await wait.started;
// The screen resets ops and entries separately, then the debounced flush sends the empty configuration.
h.target.set({ ops: {} });
h.target.set({ entries: {} });
await h.timers.advance(SYNC_DEBOUNCE_MS);
assert.deepEqual(h.net.sent, [empty]);
assert.equal(h.target.get().sync, 'synced');
wait.resolve(BACKUPS);
await old;
assert.deepEqual(h.net.sent, [empty], 'the late old skill must never go back to the server');
assert.deepEqual(h.target.get().entries, {});
assert.deepEqual(h.target.get().ops, {});
assert.equal(h.target.get().sync, 'synced');
});
test('sync: a later nonempty edit wins while the old backups preparation is waiting', async (t) => {
const wait = waitingData(); let calls = 0;
const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.target.set({ entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } });
await h.flush();
wait.resolve(BACKUPS);
await old;
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } }]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: an edit invalidates preparation before its debounce runs', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.target.set({ entries: {}, ops: {} });
wait.resolve(BACKUPS);
await old;
assert.deepEqual(h.net.sent, [], 'no stale message during the new edit debounce');
assert.equal(h.target.get().sync, 'pending');
await h.timers.advance(SYNC_DEBOUNCE_MS);
assert.deepEqual(h.net.sent, [empty]);
assert.equal(h.target.get().sync, 'synced');
});
for (const failure of ['missing', 'rejected']) test(`sync: obsolete ${failure} data does not replace the accepted reset status`, async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
t.mock.method(console, 'warn', () => {});
const old = h.flush();
await wait.started;
h.target.set({ entries: {}, ops: {} });
await h.flush();
if (failure === 'missing') wait.resolve(null); else wait.reject(new Error('download failed'));
await old;
assert.deepEqual(h.net.sent, [empty]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: welcome invalidates preparation from the earlier session', async (t) => {
const wait = waitingData(); let calls = 0;
const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.net.emit('welcome');
await h.timers.advance(50);
assert.equal(h.target.get().sync, 'synced');
wait.resolve(null);
await old;
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: an obsolete result does not swallow the newest reset RATE retry', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.target.set({ entries: {}, ops: {} });
h.net.failures.push('RATE');
await h.flush();
assert.equal(h.target.get().sync, 'pending');
wait.resolve(BACKUPS);
await old;
assert.equal(h.target.get().sync, 'pending');
await h.timers.advance(RETRY_MS);
assert.deepEqual(h.net.sent, [empty, empty]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: a current missing-data preparation remains an error and can be retried', async (t) => {
let missing = true;
const h = harness({ backups: async () => missing ? null : BACKUPS });
t.after(h.dispose);
await h.flush();
assert.deepEqual(h.net.sent, []);
assert.equal(h.target.get().sync, 'error');
missing = false;
await h.flush();
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: unrelated screen state changes do not cancel a current preparation', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const current = h.flush();
await wait.started;
h.target.set({ sel: INSIDE });
wait.resolve(BACKUPS);
await current;
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
assert.equal(h.target.get().sync, 'synced');
});