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

+3
View File
@@ -2,6 +2,9 @@
## 0.2.5-dev — 未发布
- 干员调配同步在等待游戏数据时,恢复默认或之后的修改会使仍在准备的旧请求作废,避免旧技能写回服务器(PR #540,感谢 @Xinglan233)。
- 导入带潜能/练度的调配文件时,若缺少自选编队所需的游戏数据,整份导入会被拒绝,不再清掉已保存的练度(PR #538,感谢 @Xinglan233)。
- 玩家包里的 package.json 只保留指向包内文件的脚本;`npm test` 不再在没有测试的情况下显示通过。打包进去的说明里,指向未随包发布的文件的链接改为该版本标签的 GitHub 地址(#531、#530)。
- 补齐司霆惊蛰二技能、斩业星熊三技能对空及予愿安洁莉娜三技能周身范围的自动开技判定。
- 首领配对改为每局随机,准备期至开战固定,隐藏首领沿用同局分组;客户端棋盘和队友框同步实际配对。
- 修复联防阿戈尔继承残血后再次结算开场吞噬、误触发复活的问题,保留准确生命比例和技力。
+1
View File
@@ -326,6 +326,7 @@ npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。日文干员语音(`audio.voiceJp`,`public/assets/audio/voice/jp/` 的 2674 个文件,约 85 MB,zip 后约 76 MB)默认也打进完整包;`tools/package.mjs` 里的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时,完整包(以及由它比较出的更新包)不带这些文件:玩家首次启动时 setup 会像精简包那样下载它们,下载完成前选「日本語」会播中文语音,更新包也不会删除玩家已有的日文语音。
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
- **玩家包里的脚本和文档链接**:临时目录里的 `package.json` 只留下每条 `node <文件>` 都指向包内文件、并且至少指出一个文件的脚本(`start`、`dev`、`setup`、`doctor`、`launch`、`postinstall`、`vendor`、`assets`)。`test`、`golden`、数据构建、lint、本工具等不留,避免玩家运行 `npm test` 时在没有测试文件的情况下得到「0 个测试、成功退出」。`README.md`、`CHANGELOG.md`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`docs/PLAYING.md` 和本文里,指向包内没有的文件的相对链接改成该版本 GitHub 标签上的同一路径(版本号去掉末尾的 `-dev` 后加 `v`;图片用 `raw`,其余用 `blob`);`../../releases/latest` 改为仓库的 Releases 地址。源码仓库里的这些文件仍是相对链接。
- **MANIFEST.json**:三种包的根目录都有(`npm ci` 之后写入):除素材(`public/assets/`、`public/fonts/`、`data/assets.json`、`data/local-assets.json`,归 setup 管)以外每个文件的大小和 sha256,同一版本的三种包内容相同。`npm run doctor` 和更新包的启动检查(`server/update.js`)用它核对安装。
- **更新包**(0.2.1 起,每个版本都发):`--from` 后面列出**之前每个 0.2.x 版本的完整包**(Releases 上的 zip,或它解压出来、没动过的文件夹;逗号分隔或写多个 `--from`),例如发布 0.2.2 时 `--from Stronghold-Protocol-v0.2.0.zip,Stronghold-Protocol-v0.2.1.zip`。工具照常构建完整包的临时目录(`npm ci` 等),逐个文件(大小 + sha256)和每个旧版本比较:和任何一个旧版本不同、或旧版本没有的文件都打进去,所以一个更新包能覆盖在列出的每个版本上;旧版本有、新版本没有的文件记进 `UPDATE.json` 的 `removed`(连同各旧版本里的 sha256,玩家那边只删内容一致的文件;只差大小写的同名文件和 `.env` 之类的本机文件名不删,摘要里会列出)。旧版本必须比当前版本旧、带素材(不能是精简包或更新包)、每个版本只给一次,`--dry-run` 只读取并检查旧版本。更新包里还有新版本的 `MANIFEST.json` 和 `UPDATE.json`(适用的旧版本、文件数、字节数、文件列表和 `removed`),同样经过拒绝名单和个人信息检查;摘要列出和每个旧版本相比改动 / 新增 / 删除的文件数和更新包里各类文件的数量。发布时在 Releases 说明里写明更新包适用的版本(即 `--from` 列出的版本)。`--keep-stage` 保留更新包目录和完整包的临时目录(`<目录>/Stronghold-Protocol-v<版本>-update/` 里的 `Stronghold-Protocol/` 和 `.full/`)。
- **需要**:已下载素材的仓库(完整包)——打包前先联网运行一次 `node tools/fetch-assets.mjs`,补齐清单计划但本机还没有的素材(清单只列出磁盘上有的文件,打包工具看不出缺了哪些;`data/assets.json` 有变化就先提交);能访问 npm 的网络(`npm ci`);`zip`(或 bsdtar 的 `tar`,Windows 10 起自带);更新包还要之前各版本的完整包(工具自己读 zip,不需要 `unzip`;Releases 上可以重新下载)。`--out` 默认是系统临时目录下的 `stronghold-protocol-release`,不能在仓库里面;`--force` 覆盖已有的 zip,`--keep-stage` 保留打包用的目录供检查。
+2
View File
@@ -778,6 +778,8 @@ leaker's own capsule / row (`ui/hud.js uniteRemaining`) and the leakers' team ro
## 5. Views (DESIGN §8.2 / §8.3) and deviations
The client's stored loadout and `m.private.loadout` / `ops` must agree after a successful sync. In `public/js/ui/loadoutSync.js`, edits and `welcome` invalidate preparation still awaiting game data, so an older payload cannot overwrite a newer reset on the session or seat, or enter the next match (DESIGN §16, DESIGN §30.17). Request reply ordering, debounce, close flush and retry/lock handling remain unchanged.
`m.public` (throttled ≤ 10/s, only sent when it changed) carries the DESIGN fields plus: `drawnDisabledBonds` (the 3+4
drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`,
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
+3
View File
@@ -106,6 +106,8 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
**Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
Client sync preparation may await chess/backups data. A later edit (including restoring defaults) or `welcome` invalidates the earlier preparation: its payload, missing-data result or failure cannot send or change sync status. The current edit keeps the existing debounce, close flush, ordered requests and retry/lock handling (`public/js/ui/loadoutSync.js`).
**潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」):** official — an operator fights at the player's own potential (调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; an unowned 特许 operator at potential 1) and an owned operator of the same name gets the 自持有 练度 bonus (activity_table `cultivateEffectList`: 未精英化 +0 %, 精英1 ATK / DEF +5 %, 精英2 ATK +10 % DEF / HP +5 %, 精英2 Lv.60 +10 % each — a multiplier of its own, never summed with the 直接乘算 bonuses). The 干员调配 screen sets both per operator (by charId: initial, elite and 自选 forms share them; default 潜能 6 and 精英2 Lv.60); they travel as `room.loadout.ops` (`{ [charId]: { potential, cultivate } }`, defaults dropped; shared/protocol.js `checkLoadoutOps`, strict like the entries), `seats[].ops` → `PlayerState.ops` (re-checked; bots none = the defaults), and every operator the player owns states them in its `PlayerBattleInput` entry (`potential`, `cultivate`; shared/potential.js `cultivationOf`) — never a 补位 stand-in (a not-owned NORMAL chess) or a prototype 自选 pick. The sim composes the def at the potential (data/*.json carry every potential, DATA.md §2.3; the summons' talents at the owner's) and multiplies ATK / DEF / max HP by the tier's `char_attribute_mul` (effects.json `aceffect_char_1…4`, SIM.md §12). The cards (局内数值, shop and detail cards, a teammate's unit from UnitInfo `potential` / `cultivate`) show the numbers at the settings.
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
@@ -113,6 +115,7 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the roster is one list, a row per operator with aligned columns under a head row — portrait / name / bonds (opens the detail), the skills and the elite's modules to tap (不装备 first; the quick choices of PR #301 by @farridge, laid out as one list instead of its three columns of cards, the owner's call of 2026-10-08), the 潜能 / 练度 selects (`screens/loadout.js RosterRow`, `screens/cultivation.js`); the detail beside it (below 1000 px wide it slides over the list, a quick choice keeps the list) opens with the 潜能与练度 section (潜能 1–6, the four official 练度 tiers with their effect texts); the detail then shows the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries, ops }` (`ops`: the 潜能 / 练度 settings, 0.2.2 — an import without them leaves the current settings alone) — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
A payload that carries `ops`, including `ops: {}`, is refused before any write when chess.json or backups.json is missing (`loadoutOpsImportRefusal` in `screens/loadout.js`, the same check as a 自选编队 import). An older file with no `ops` still imports skills and modules once chess is ready and leaves the current 练度 in place.
Export remains available for non-default `ops` alone, including operators outside the normal roster. The roster's
adjusted count and reset scope stay limited to its own operators; the DIY export still carries picks only.
+43
View File
@@ -271,3 +271,46 @@ the preceding digest exactly; the changed partner/side also changes preparation
All five already differed from 0.2.4, so the final release-relative total remains 45 scenarios (§30.9 plus these
additional pairing effects). There are no new corpus cases or removals. The other follow-up fixes do not move this
corpus; their dedicated regressions cover the reported and newly found cases.
### 30.17 Loadout sync preparation
PR #540 by @Xinglan233. Restoring defaults while a loadout sync was still waiting on backups.json could put the
previous skill back on the server. `prepare()` assigned its request number only after the await, so the older
payload was treated as the newer one. A stale missing-data result or a failed download could also replace a sync
the reset had already finished.
`installPrefSync` now keeps a preparation revision. An edit of a watched field, including restoring defaults, and
`welcome` increment it before the new flush is scheduled. A preparation that started at an older revision does not
send and does not change sync status when it finishes, whether it built a payload, found the data missing, or
failed. Debounce, the close flush, request ordering and the RATE / lock retry stay as they are. The same engine
covers 干员持有 and 自选编队. Golden unchanged. Tests: `test/ui/loadout-sync-stale.test.js`.
### 30.18 Loadout import with ops while backups are missing
PR #538 by @Xinglan233. Importing a loadout file that includes `ops` checked only that chess.json was loaded.
`cultivationCharIds` then dropped 自选 operators, which only backups.json names, and the import still saved. That
cleared a 练度 the file was not meant to erase. `ops: {}` is the same replacement.
After a successful parse, `loadoutOpsImportRefusal` refuses the whole import with the 自选编队 data check
(`importRefusal('diy')`) before any write. A file with no `ops` still imports skills and modules once chess.json is
ready and leaves the current 练度 in place. Golden unchanged. Tests: `test/ui/loadout-ops-import.test.js` (node, no
browser).
### 30.19 Player package scripts and doc links
GitHub #531 and #530. The player zip copied the repository `package.json`. `npm test` is `node --test`, and `test/`
is not in the zip, so it reported 0 tests and exited 0. Nine other scripts name files or tools the zip does not
contain (`golden`, `golden:update`, `build-data`, `check:imports`, `ci`, `package`, `package:lite`, `lint`,
`typecheck`). README, CHANGELOG, NOTICE, THIRD-PARTY-NOTICES, PLAYING and DEPLOY also keep relative links to
developer docs, pictures and `../../releases/latest`. Twenty-three of those, in README, PLAYING and DEPLOY, point
at developer files.
The stage rewrites `package.json` only. A script stays when every `node <file>` it names is in the zip and it names
at least one (`start`, `dev`, `setup`, `doctor`, `launch`, `postinstall`, `vendor`, `assets`). The others are
removed, so `npm test` is a missing script and exits non-zero. The repository `package.json` is unchanged. The same
stage rewrites those six Markdown files: a relative link whose target ships stays relative; a picture becomes
`https://github.com/sganggs/Stronghold-Protocol/raw/v<tag>/<path>`; another file becomes `.../blob/v<tag>/<path>`;
`../../releases/latest` becomes `.../releases/latest`. The tag is `v` plus the package version with a trailing
`-dev` removed. [ASSUMED] an unreleased `0.2.5-dev` build points those links at `v0.2.5`, the tag that version will
use. Source copies in the git checkout stay relative. Golden unchanged. Tests: `test/package-player.test.js`,
`test/package.test.js`, `test/docs-paths.test.js`.
+18
View File
@@ -451,6 +451,20 @@ export function importRefusal(kind, ready, status = data.status) {
return null;
}
/**
* A loadout payload that carries `ops` (including empty `ops`) replaces 潜能 / 练度. 自选 operators are in that list
* and only backups.json names them, so the refusal is the 自选编队 one: chess.json and backups.json both loaded.
* Null when the payload has no `ops` (an older file leaves the current 练度 alone) or that data is ready (GitHub #538).
* @param {Record<string, any> | null | undefined} ops
* @param {boolean} ready
* @param {(name: string) => string} [status]
* @returns {{ text: string, tone: 'warn'|'error' } | null}
*/
export function loadoutOpsImportRefusal(ops, ready, status = data.status) {
if (ops == null) return null;
return importRefusal('diy', ready, status);
}
/** In place of a tab whose game data did not load (missingGameData): which files, and what to try (GitHub #173). */
export function DataMissing({ files }) {
const urls = files.map((name) => `/data/${DATA_FILES[name] || `${name}.json`}`);
@@ -586,6 +600,10 @@ function LoadoutScreen({ st }) {
}
const res = parseImport(ioText);
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
// ops replaces 潜能 / 练度, including 自选 operators that only backups.json names. Missing data used to drop
// them and still save, clearing a 练度 the file did not mean to erase (GitHub #538).
const opsRefused = loadoutOpsImportRefusal(res.ops, ready);
if (opsRefused) { toast(opsRefused.text, opsRefused.tone); return; }
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
+8 -3
View File
@@ -169,6 +169,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
const tell = notify || ((text) => toast(t(text), 'warn'));
let timer = null;
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
let revision = 0; // edits and welcome invalidate a preparation still awaiting game data (GitHub #540)
let pendingJson = null; // JSON of the newest request still awaiting its reply
let lastSent = null; // JSON of the last payload the server accepted (on this session)
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
@@ -189,9 +190,12 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
async function flush() {
if (disposed) return;
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
const preparing = revision;
try {
const payload = await prepare();
if (disposed) return;
// a newer edit or welcome happened while prepare awaited chess/backups: do not send, and do not touch the
// sync state that newer flush owns (an older skill must not overwrite a restore-defaults)
if (disposed || preparing !== revision) return;
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
if (payload == null) { setState('error'); return; }
const json = JSON.stringify(payload);
@@ -221,15 +225,16 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); }
}
} catch (e) {
if (disposed || preparing !== revision) return;
console.warn(`[${tag}] sync failed`, e);
setState('error');
}
}
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
const offWelcome = net.on('welcome', () => { revision++; lastSent = null; pendingJson = null; seq++; schedule(50); });
const keys = Array.isArray(key) ? key : [key];
const offStore = target.subscribe((s, prev) => {
if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); }
if (keys.some((k) => s[k] !== prev[k])) { revision++; edited = true; schedule(); }
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
+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');
});
+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'}`);