- 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).
488 lines
26 KiB
JavaScript
Executable File
488 lines
26 KiB
JavaScript
Executable File
#!/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;
|
||
});
|
||
}
|