Files
ark-stronghold-protocol/tools/setup.mjs
T
sganggs bb5c792a4d Node 22+ everywhere, LF text on Windows checkouts, CI actions v7
- Node minimum is 22 in package.json engines, the start scripts, the service installer, setup and doctor,
  matching the README, the release notes and the CI matrix (22 / 24).
- .gitattributes: CSS, HTML, JSON, Markdown and YAML check out with LF, so `node --test` on Windows matches the
  CSS rules the UI tests read; the Windows test step is no longer continue-on-error.
- CI: actions/checkout@v7 and actions/setup-node@v7 (Node 24 runtime).
2026-10-02 16:51:44 +08:00

488 lines
26 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// tools/setup.mjs — prepare a fresh clone in one command (docs/DEPLOY.md).
//
// node tools/setup.mjs [options]
//
// Steps (each one is skipped when already done, so re-running is cheap — the start scripts run it on every start):
// 1. Node.js ≥ 22 check (clear message + download link otherwise).
// 2. Dependencies: `npm ci` (falls back to `npm install`) when node_modules is missing or incomplete.
// 3. Client libraries in public/vendor (tools/vendor.mjs) when any is missing.
// 4. Game data (data/*.json, committed) present and parseable.
// 5. Art/audio (tools/fetch-assets.mjs, ~250 MB into public/assets, resumable, mirror fallback) when public/assets
// is missing or data/assets.json lists files that are not on disk. A failure is a warning: the game still runs
// with fallback visuals and the next run resumes.
// 6. Optional: official board/UI art from a locally installed Arknights client (Windows native install, CrossOver
// bottle or PlayCover on macOS, or --game <dir>) with tools/local-extract/extract.py in a project-local Python
// venv (.venv-extract), then the board tile crops (tools/crop-board-atlas.mjs → tiles.json). Asked once on a
// terminal (the answer is remembered in .cache/setup-state.json; without a terminal it is skipped); never fatal.
//
// Options:
// --check report only, change nothing (exit 1 when something essential is missing)
// --no-assets skip the art/audio download
// --no-local skip the local-client detection and extraction
// --local extract from the local client without asking (re-extracts when already done)
// --game <dir> AssetBundle root of the local client (…/StreamingAssets/AB/Windows or PlayCover …/Documents/Bundles)
// -y, --yes answer "yes" to every question
// --quiet fewer lines (used by scripts/launch.mjs)
// -h, --help
//
// Exit code: 0 = ready to `npm start` (optional parts may have been skipped), 1 = something essential is missing.
// Helpers are exported for tools/doctor.mjs and scripts/launch.mjs; main() only runs when executed directly.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const MIN_NODE = 22;
export const IS_WIN = process.platform === 'win32';
export const IS_MAC = process.platform === 'darwin';
const NODE_URL = 'https://nodejs.org/zh-cn/download';
/** Data files the server expects (server/data.js DATA_FILES) + the emote catalogue used by the client. */
export const DATA_FILES = ['config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices',
'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'assets', 'emotes'];
/** Runtime packages that must be installed (package.json dependencies). */
export const RUNTIME_PACKAGES = ['ws', 'pixi.js', 'pixi-spine', 'preact', 'htm'];
/** Vendor files the client cannot run without (tools/vendor.mjs; three.js is optional there). */
export const VENDOR_REQUIRED = ['pixi.min.js', 'pixi-spine.js', 'preact.module.js', 'hooks.module.js', 'htm.module.js'];
export const VENDOR_OPTIONAL = ['three.core.js', 'three.module.js'];
const STATE_FILE = path.join(ROOT, '.cache', 'setup-state.json');
export const VENV_DIR = path.join(ROOT, '.venv-extract');
const EXTRACT_PY = path.join(ROOT, 'tools', 'local-extract', 'extract.py');
const EXTRACT_REQ = path.join(ROOT, 'tools', 'local-extract', 'requirements.txt');
const LOCAL_MANIFEST = path.join(ROOT, 'data', 'local-assets.json');
const LOCAL_BOARD_ATLAS = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'TX_autochessi_D.png');
const LOCAL_BOARD_TILES = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'tiles.json');
// ---------------------------------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------------------------------
const color = process.stdout.isTTY && !process.env.NO_COLOR;
const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : String(s));
export const c = { ok: paint('32'), warn: paint('33'), err: paint('31'), dim: paint('2'), bold: paint('1'), cyan: paint('36') };
export const mark = { ok: c.ok('✔'), warn: c.warn('!'), err: c.err('✘'), skip: c.dim('–') };
export const nodeMajor = () => Number(process.versions.node.split('.')[0]);
const exists = (p) => { try { fs.accessSync(p); return true; } catch { return false; } };
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
const mb = (n) => `${(n / 1048576).toFixed(0)} MB`;
/** Terminal display width (CJK / full-width characters take two columns). */
export const displayWidth = (s) => [...String(s)].reduce((n, ch) => n + (ch.codePointAt(0) >= 0x2e80 ? 2 : 1), 0);
export const padDisplay = (s, w) => s + ' '.repeat(Math.max(0, w - displayWidth(s)));
/** Run a command with inherited stdio. `.cmd` shims (npm) need a shell on Windows (Node ≥ 18.20 refuses them otherwise). */
function run(cmd, args, { cwd = ROOT, env, shell = false } = {}) {
const r = spawnSync(cmd, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env }, shell });
if (r.error) return { ok: false, code: -1, error: r.error };
return { ok: r.status === 0, code: r.status };
}
/** Run a command and capture its output (never throws). */
export function capture(cmd, args, { timeout = 15000, shell = false, env } = {}) {
try {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout, shell, windowsHide: true, env: env ? { ...process.env, ...env } : process.env });
return { ok: !r.error && r.status === 0, status: r.status, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
} catch (e) {
return { ok: false, status: -1, out: String(e?.message || e) };
}
}
function npmCommand() {
const execPath = process.env.npm_execpath; // set when started through `npm run …`
if (execPath && /npm-cli\.js$/i.test(execPath) && exists(execPath)) return { cmd: process.execPath, pre: [execPath], shell: false };
return { cmd: IS_WIN ? 'npm.cmd' : 'npm', pre: [], shell: IS_WIN };
}
function loadState() { return readJson(STATE_FILE) || {}; }
function saveState(patch) {
try {
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...loadState(), ...patch }, null, 1) + '\n');
} catch { /* best effort */ }
}
/** Ask a yes/no question on a TTY (default on Enter / timeout = `def`). Non-TTY → null (nobody to ask). */
async function ask(question, def, timeoutMs = 60000) {
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const hint = def ? '[Y/n]' : '[y/N]';
try {
return await new Promise((resolve) => {
const t = setTimeout(() => { process.stdout.write(c.dim(`\n (${timeoutMs / 1000} 秒无输入,按默认处理)\n`)); resolve(def); }, timeoutMs);
rl.question(` ${question} ${hint} `, (a) => {
clearTimeout(t);
const s = String(a || '').trim().toLowerCase();
resolve(s === '' ? def : /^(y|yes|是|好|1)$/.test(s));
});
});
} finally {
rl.close();
}
}
// ---------------------------------------------------------------------------------------------------
// Checks (exported for tools/doctor.mjs)
// ---------------------------------------------------------------------------------------------------
/** Node.js version check. */
export function checkNode() {
const major = nodeMajor();
return { ok: major >= MIN_NODE, version: process.versions.node, major, recommended: major >= MIN_NODE };
}
/** Runtime dependencies installed? */
export function checkDeps() {
const missing = RUNTIME_PACKAGES.filter((p) => !exists(path.join(ROOT, 'node_modules', ...p.split('/'), 'package.json')));
return { ok: missing.length === 0, missing, hasNodeModules: exists(path.join(ROOT, 'node_modules')) };
}
/** public/vendor files present? */
export function checkVendor() {
const dir = path.join(ROOT, 'public', 'vendor');
const missing = VENDOR_REQUIRED.filter((f) => !exists(path.join(dir, f)));
const optionalMissing = VENDOR_OPTIONAL.filter((f) => !exists(path.join(dir, f)));
return { ok: missing.length === 0, missing, optionalMissing };
}
/** data/*.json present and parseable? */
export function checkData() {
const missing = [];
const broken = [];
for (const name of DATA_FILES) {
const p = path.join(ROOT, 'data', `${name}.json`);
if (!exists(p)) { missing.push(name); continue; }
if (readJson(p) == null) broken.push(name);
}
return { ok: !missing.length && !broken.length, missing, broken };
}
/** Every '/assets/…' or '/fonts/…' URL of the manifest (same walk as test/assets.test.js). */
function manifestUrls(node, out = []) {
if (typeof node === 'string') { if (/^\/(assets|fonts)\//.test(node)) out.push(node); }
else if (Array.isArray(node)) for (const x of node) manifestUrls(x, out);
else if (node && typeof node === 'object') for (const x of Object.values(node)) manifestUrls(x, out);
return out;
}
/**
* Downloaded art/audio complete? Compares data/assets.json with public/.
* @returns {{ ok: boolean, present: boolean, manifest: boolean, total: number, missing: number, sample: string[], bytes: number }}
*/
export function checkAssets() {
const pub = path.join(ROOT, 'public');
const present = exists(path.join(pub, 'assets'));
const m = readJson(path.join(ROOT, 'data', 'assets.json'));
if (!m) return { ok: false, present, manifest: false, total: 0, missing: 0, sample: [], bytes: 0 };
const urls = [...new Set(manifestUrls(m))];
const missing = [];
for (const u of urls) {
let st = null;
try { st = fs.statSync(path.join(pub, ...u.split('/').filter(Boolean).map(decodeURIComponent))); } catch { /* missing */ }
if (!st || !st.size) missing.push(u);
}
return { ok: present && missing.length === 0 && urls.length > 0, present, manifest: true, total: urls.length, missing: missing.length, sample: missing.slice(0, 5), bytes: Number(m.stats?.bytes) || 0 };
}
/** Local-client art (optional): manifest entry count and whether the 3D board atlas is on disk. */
export function checkLocal() {
const m = readJson(LOCAL_MANIFEST);
const count = m && m.groups ? Object.values(m.groups).reduce((n, g) => n + Object.keys(g || {}).length, 0) : 0;
return { manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')) };
}
/**
* Board tile crops of the extracted atlas (tools/crop-board-atlas.mjs → tiles.json, read by the 2D and 3D board
* renderers; without it the client requests a missing file and keeps the procedural tiles). Output only on failure.
*/
function cropBoardTiles(log) {
const r = capture(process.execPath, [path.join(ROOT, 'tools', 'crop-board-atlas.mjs')], { timeout: 120000 });
if (!r.ok) log(c.warn(` 棋盘贴图裁切失败(node tools/crop-board-atlas.mjs):${r.out.split(/\r?\n/).slice(-3).join(' ')}`));
return r.ok && exists(LOCAL_BOARD_TILES);
}
// ---------------------------------------------------------------------------------------------------
// Local Arknights client detection
// ---------------------------------------------------------------------------------------------------
const AB_TAIL = ['Arknights_Data', 'StreamingAssets', 'AB', 'Windows'];
/** Candidate AssetBundle roots for this OS (existence not checked). */
export function clientCandidates() {
const home = os.homedir();
const out = [];
const add = (p, kind) => out.push({ path: p, kind });
if (IS_WIN) {
const drives = ['C', 'D', 'E', 'F', 'G', 'H'];
const bases = [
['Program Files', 'Hypergryph Launcher', 'games', 'Arknights'],
['Program Files (x86)', 'Hypergryph Launcher', 'games', 'Arknights'],
['Hypergryph Launcher', 'games', 'Arknights'],
['Games', 'Hypergryph Launcher', 'games', 'Arknights'],
['Program Files', 'Hypergryph', 'Arknights'],
['Arknights'],
];
for (const d of drives) for (const b of bases) add(path.win32.join(`${d}:\\`, ...b, ...AB_TAIL), 'Windows');
}
if (IS_MAC) {
const bottles = path.join(home, 'Library', 'Application Support', 'CrossOver', 'Bottles');
let names = [];
try { names = fs.readdirSync(bottles); } catch { /* no CrossOver */ }
names.sort((a, b) => (b === 'Arknights') - (a === 'Arknights'));
for (const n of names) {
for (const pf of ['Program Files', 'Program Files (x86)']) {
add(path.join(bottles, n, 'drive_c', pf, 'Hypergryph Launcher', 'games', 'Arknights', ...AB_TAIL), `CrossOver (${n})`);
}
}
add(path.join(home, 'Library', 'Containers', 'com.hypergryph.arknights', 'Data', 'Documents', 'Bundles'), 'PlayCover');
}
if (!IS_WIN) {
for (const prefix of [path.join(home, '.wine'), path.join(home, 'Games', 'arknights')]) {
add(path.join(prefix, 'drive_c', 'Program Files', 'Hypergryph Launcher', 'games', 'Arknights', ...AB_TAIL), 'Wine');
}
}
return out;
}
/** Does the directory look like an AssetBundle root holding the autochess bundles? */
export function inspectClientRoot(dir) {
if (!dir || !exists(dir)) return { exists: false, autochess: false };
const autochess = exists(path.join(dir, 'ui', 'autochess')) || exists(path.join(dir, 'arts', 'maps', 'map_autochess'));
return { exists: true, autochess };
}
/** First installed client (explicit dir first). */
export function findClient(explicit) {
const list = explicit ? [{ path: path.resolve(explicit), kind: '--game' }] : clientCandidates();
let partial = null;
for (const cand of list) {
const info = inspectClientRoot(cand.path);
if (info.exists && info.autochess) return { ...cand, ...info };
if (info.exists && !partial) partial = { ...cand, ...info };
}
return partial;
}
// ---------------------------------------------------------------------------------------------------
// Python (optional)
// ---------------------------------------------------------------------------------------------------
/** A usable Python ≥ 3.8 launcher: { cmd, args, version } or null. Windows Store stubs are skipped (no version). */
export function findPython() {
const cands = IS_WIN ? [['py', ['-3']], ['python', []], ['python3', []]] : [['python3', []], ['python', []]];
for (const [cmd, pre] of cands) {
const r = capture(cmd, [...pre, '--version'], { timeout: 10000 });
const m = /Python (\d+)\.(\d+)(?:\.(\d+))?/.exec(r.out);
if (!r.ok || !m) continue;
const major = Number(m[1]);
const minor = Number(m[2]);
if (major === 3 && minor >= 8) return { cmd, args: pre, version: `${m[1]}.${m[2]}${m[3] ? '.' + m[3] : ''}`, minor };
}
return null;
}
export function venvPython() {
return IS_WIN ? path.join(VENV_DIR, 'Scripts', 'python.exe') : path.join(VENV_DIR, 'bin', 'python');
}
const PY_ENV = { PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', PYTHONDONTWRITEBYTECODE: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1' };
/** Create .venv-extract and install tools/local-extract/requirements.txt (skipped when the imports already work). */
function ensureVenv(py, log) {
const vpy = venvPython();
if (!exists(vpy)) {
log(` 创建 Python 虚拟环境 ${path.relative(ROOT, VENV_DIR)} …`);
const r = run(py.cmd, [...py.args, '-m', 'venv', VENV_DIR], { env: PY_ENV });
if (!r.ok || !exists(vpy)) return { ok: false, why: 'python -m venv 失败(Debian/Ubuntu 需要 `sudo apt install python3-venv`)' };
}
if (capture(vpy, ['-c', 'import UnityPy, lz4, PIL'], { env: PY_ENV }).ok) return { ok: true, python: vpy };
log(' 安装 UnityPy / lz4 / Pillow(首次约 1–3 分钟)…');
const r = run(vpy, ['-m', 'pip', 'install', '-r', EXTRACT_REQ], { env: PY_ENV });
if (!r.ok || !capture(vpy, ['-c', 'import UnityPy, lz4, PIL'], { env: PY_ENV }).ok) {
return { ok: false, why: 'pip 安装依赖失败(若 Python 版本过新导致没有预编译包,请安装 Python 3.12 后删除 .venv-extract 重试)' };
}
return { ok: true, python: vpy };
}
// ---------------------------------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------------------------------
function parseArgs(argv) {
const o = { check: false, assets: true, local: 'ask', game: null, yes: false, quiet: false, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--check') o.check = true;
else if (a === '--no-assets') o.assets = false;
else if (a === '--no-local') o.local = 'no';
else if (a === '--local') o.local = 'force';
else if (a === '--game') { o.game = argv[++i] || null; if (o.local !== 'no') o.local = 'force'; }
else if (a.startsWith('--game=')) { o.game = a.slice(7) || null; if (o.local !== 'no') o.local = 'force'; }
else if (a === '-y' || a === '--yes') o.yes = true;
else if (a === '--quiet' || a === '-q') o.quiet = true;
else if (a === '-h' || a === '--help') o.help = true;
else throw new Error(`未知参数 / unknown option: ${a}(--help 查看用法)`);
}
return o;
}
function helpText() {
const src = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n');
const start = src.findIndex((l) => l.startsWith('// node tools/setup.mjs'));
const end = src.findIndex((l, i) => i > start && l.startsWith('// Exit code'));
return src.slice(start, end + 1).map((l) => l.replace(/^\/\/ ?/, '')).join('\n');
}
async function main() {
let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); return 2; }
if (opts.help) { console.log(helpText()); return 0; }
const log = (s = '') => console.log(s);
const say = (s) => { if (!opts.quiet) log(s); };
const summary = [];
const add = (state, label, detail = '') => summary.push({ state, label, detail });
let fatal = false;
say(c.bold('\n卫戍协议:盟约 · setup') + c.dim(` (${ROOT})`));
// 1. Node
const node = checkNode();
if (!node.ok) {
log(`${mark.err} Node.js ${node.version} 太旧:需要 ${MIN_NODE} 或更高(22 / 24 LTS)。`);
log(` 下载:${NODE_URL}` + (IS_WIN ? ' 或在终端运行:winget install OpenJS.NodeJS.LTS' : IS_MAC ? ' 或:brew install node@22' : ''));
return 1;
}
add('ok', 'Node.js', `v${node.version}${node.recommended ? '' : '(可用;推荐 22 / 24 LTS)'}`);
// 2. npm dependencies
let deps = checkDeps();
if (!deps.ok && !opts.check) {
log(`\n${c.cyan('▶')} 安装依赖(npm ci)…`);
const npm = npmCommand();
let r = run(npm.cmd, [...npm.pre, 'ci', '--no-audit', '--no-fund'], { shell: npm.shell });
if (!r.ok) {
log(c.warn(' npm ci 失败,改用 npm install …'));
r = run(npm.cmd, [...npm.pre, 'install', '--no-audit', '--no-fund'], { shell: npm.shell });
}
deps = checkDeps();
}
if (deps.ok) add('ok', '依赖 node_modules');
else { add('err', '依赖 node_modules', `缺少 ${deps.missing.join(', ')} → 运行 npm install`); fatal = true; }
// 3. vendor
let vendor = checkVendor();
if (!vendor.ok && deps.ok && !opts.check) {
log(`\n${c.cyan('▶')} 复制前端库到 public/vendor …`);
run(process.execPath, [path.join(ROOT, 'tools', 'vendor.mjs')]);
vendor = checkVendor();
}
if (vendor.ok) add('ok', '前端库 public/vendor', vendor.optionalMissing.length ? '(three.js 缺失:3D 棋盘回退为 2D)' : '');
else { add('err', '前端库 public/vendor', `缺少 ${vendor.missing.join(', ')} → 运行 node tools/vendor.mjs`); fatal = true; }
// 4. data
const data = checkData();
if (data.ok) add('ok', '游戏数据 data/*.json');
else {
add('err', '游戏数据 data/*.json', [data.missing.length && `缺少 ${data.missing.join(', ')}`, data.broken.length && `无法解析 ${data.broken.join(', ')}`].filter(Boolean).join(';') + ' → git checkout -- data/ 或 node tools/build-data.mjs');
if (data.missing.some((n) => n !== 'assets' && n !== 'emotes') || data.broken.length > 0) fatal = true;
}
// 5. assets
let assets = checkAssets();
if (!opts.assets) add(assets.ok ? 'ok' : 'skip', '美术/音频 public/assets', assets.ok ? `${assets.total} 个文件` : '已跳过(--no-assets)');
else if (!assets.ok && deps.ok && !opts.check) {
const what = !assets.present ? `首次下载约 ${assets.bytes ? mb(assets.bytes) : '250 MB'},可随时中断,重新运行会续传`
: `补全缺失的 ${assets.missing} 个文件`;
log(`\n${c.cyan('▶')} 下载美术与音频素材(${what})…`);
const r = run(process.execPath, [path.join(ROOT, 'tools', 'fetch-assets.mjs')]);
assets = checkAssets();
if (!r.ok && !assets.ok) log(c.warn(' 素材下载未完成(网络问题?)。游戏仍可运行(使用占位图),稍后重新运行 setup 即可续传。'));
}
if (opts.assets) {
if (assets.ok) add('ok', '美术/音频 public/assets', `${assets.total} 个文件`);
else if (!assets.present) add('warn', '美术/音频 public/assets', '未下载(游戏会用占位图)→ node tools/fetch-assets.mjs');
else add('warn', '美术/音频 public/assets', `缺 ${assets.missing}/${assets.total} 个文件 → 重新运行 setup 续传`);
}
// 6. local client (optional)
const local = checkLocal();
const state = loadState();
if (opts.local === 'no') add(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest ? `已提取 ${local.count} 项` : '已跳过(--no-local)');
else {
const client = findClient(opts.game);
const already = local.manifest && local.dirPresent;
if (!client) {
if (already && local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
add(already ? 'ok' : 'skip', '本地客户端美术(可选)', already ? `已提取 ${local.count} 项` : (opts.game ? `找不到 ${opts.game}` : '未检测到本机明日方舟客户端(不影响游戏)'));
} else if (!client.autochess) {
add(already ? 'ok' : 'warn', '本地客户端美术(可选)', `${client.kind} 客户端缺少卫戍协议资源(请在游戏内下载全部资源):${client.path}`);
} else if (already && opts.local !== 'force') {
if (local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}(重新提取:--local)`);
} else if (opts.check) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`);
} else {
const py = findPython();
if (!py) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,但没有 Python 3.8+(${IS_WIN ? 'winget install Python.Python.3.12' : 'https://www.python.org/downloads/'})`);
} else {
let go = opts.local === 'force' || opts.yes;
if (go || !state.localDeclined) {
log(`\n${c.cyan('▶')} 检测到本机明日方舟客户端(${client.kind}):\n ${c.dim(client.path)}`);
log(' 可以从中提取官方棋盘贴图、UI 图标、表情等(仅本机使用;通常 1–5 分钟,Python 依赖约 40 MB,装在项目内的 .venv-extract)。');
}
let unattended = false;
if (!go && !state.localDeclined) {
const answer = await ask('现在提取吗?', true);
if (answer === null) unattended = true; // no terminal (service, pipe): don't install Python packages unasked
else if (!answer) { saveState({ localDeclined: true }); log(c.dim(' 已记住选择,之后不再询问;需要时运行 node tools/setup.mjs --local')); }
go = answer === true;
}
if (!go) add('skip', '本地客户端美术(可选)', unattended ? '无终端,未询问、未提取(需要时运行 node tools/setup.mjs --local)' : '已跳过(需要时运行 node tools/setup.mjs --local)');
else {
const venv = ensureVenv(py, log);
if (!venv.ok) add('warn', '本地客户端美术(可选)', venv.why);
else {
log(` ${c.cyan('▶')} 提取中(python tools/local-extract/extract.py --game …)`);
const r = run(venv.python, [EXTRACT_PY, '--game', client.path], { env: PY_ENV });
if (r.ok) cropBoardTiles(log);
const after = checkLocal();
if (r.ok && after.manifest) { add('ok', '本地客户端美术(可选)', `提取 ${after.count} 项${after.board3d ? ',3D 棋盘可用' : ''}`); saveState({ localDeclined: false, localExtractedFrom: client.path }); }
else add('warn', '本地客户端美术(可选)', `提取未成功(退出码 ${r.code}),游戏不受影响;可稍后重试 node tools/setup.mjs --local`);
}
}
}
}
}
// summary
log(c.bold('\n── 准备情况 ──────────────────────────────'));
const width = Math.max(...summary.map((s) => displayWidth(s.label))) + 2;
for (const s of summary) log(`${mark[s.state]} ${padDisplay(s.label, width)}${s.detail ? c.dim(s.detail) : ''}`);
if (fatal) {
log(c.err('\n还不能启动:请先解决上面标 ✘ 的问题(node tools/doctor.mjs 可做更详细的诊断)。'));
return 1;
}
if (!opts.quiet) {
log(`\n${c.ok('可以开始了:')} npm start ${c.dim('(Windows 可直接双击 scripts\\start-windows.bat)')}`);
log(c.dim('浏览器打开 http://localhost:3000 ;同一局域网的朋友用终端里打印的 LAN 地址。'));
}
return 0;
}
function isMain() {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
}
if (isMain()) {
main().then((code) => { process.exitCode = code; }, (e) => {
console.error(`${mark.err} setup 出错:${e?.stack || e}`);
process.exitCode = 1;
});
}