卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/launch.mjs — cross-platform "prepare + start + open the browser", used by scripts/start-windows.bat,
|
||||
// scripts/start-windows.ps1 and scripts/start.sh (docs/DEPLOY.md).
|
||||
//
|
||||
// node scripts/launch.mjs [--port 3000] [--host 0.0.0.0] [--no-open] [--no-setup] [setup options…]
|
||||
//
|
||||
// 1. If our server already answers on the port, just open the browser (double-clicking twice is harmless).
|
||||
// 2. node tools/setup.mjs --quiet (dependencies, vendor libs, art download / resume, optional local extraction);
|
||||
// setup options such as --no-assets, --no-local, --local, --game <dir>, -y are passed through.
|
||||
// 3. node server/index.js (PORT / HOST from the options or the environment; SP_COMBAT / SP_VERIFY / TRUST_PROXY /
|
||||
// DEBUG are inherited), then — once /healthz answers — prints the addresses to share and opens
|
||||
// http://localhost:<port> (not with --no-open, SP_NO_BROWSER=1, or on a Linux box without a display).
|
||||
// Ctrl+C stops the server (it gets the signal from the terminal itself); the exit code is the server's.
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const IS_WIN = process.platform === 'win32';
|
||||
|
||||
if (Number(process.versions.node.split('.')[0]) < 18) {
|
||||
console.error(`Node.js ${process.versions.node} 太旧,需要 18 或更高(推荐 22 LTS):https://nodejs.org/zh-cn/download`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { c, mark } = await import('../tools/setup.mjs');
|
||||
const { probePort, classifyAddresses, KIND_LABEL } = await import('../tools/doctor.mjs');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const [k, v] = a.split('=');
|
||||
const val = () => (v !== undefined ? v : argv[++i]);
|
||||
if (k === '--port') o.port = Number(val()) || o.port;
|
||||
else if (k === '--host') o.host = val() || o.host;
|
||||
else if (a === '--no-open') o.open = false;
|
||||
else if (a === '--no-setup') o.setup = false;
|
||||
else if (a === '-h' || a === '--help') o.help = true;
|
||||
else if (a === '--game') o.setupArgs.push(a, argv[++i] ?? '');
|
||||
else o.setupArgs.push(a);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function openBrowser(url) {
|
||||
try {
|
||||
let cmd; let args;
|
||||
if (IS_WIN) { cmd = 'rundll32'; args = ['url.dll,FileProtocolHandler', url]; }
|
||||
else if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
|
||||
else {
|
||||
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
||||
cmd = 'xdg-open'; args = [url];
|
||||
}
|
||||
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
||||
child.on('error', () => {});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function printShare(port) {
|
||||
const addrs = classifyAddresses().filter((a) => a.kind === 'lan' || a.kind === 'vpn' || a.kind === 'public');
|
||||
const line = c.dim('─'.repeat(56));
|
||||
console.log(`\n${line}`);
|
||||
console.log(`${mark.ok} ${c.bold('服务器已启动')} 本机打开:${c.cyan(`http://localhost:${port}`)}`);
|
||||
if (addrs.length) {
|
||||
console.log(' 发给朋友(需要能访问这台电脑的网络):');
|
||||
for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(`http://${a.address}:${port}`)} ${c.dim(KIND_LABEL[a.kind])}`);
|
||||
} else {
|
||||
console.log(c.warn(' 没有检测到局域网地址:朋友暂时无法连接(检查网线/Wi-Fi)。'));
|
||||
}
|
||||
console.log(c.dim(' 建房后把 4 位「同盟密钥」或「复制链接」(…/?room=密钥)发给朋友。'));
|
||||
console.log(c.dim(' 朋友打不开?运行 node tools/doctor.mjs 检查防火墙。按 Ctrl+C 停止服务器。'));
|
||||
console.log(`${line}\n`);
|
||||
}
|
||||
|
||||
async function waitHealthy(port, child, timeoutMs = 30000) {
|
||||
const until = Date.now() + timeoutMs;
|
||||
while (Date.now() < until && child.exitCode === null) {
|
||||
const p = await probePort(port);
|
||||
if (p.state === 'ours') return true;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const o = parseArgs(process.argv.slice(2));
|
||||
if (o.help) {
|
||||
const src = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n');
|
||||
console.log(src.slice(1, 13).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
|
||||
return 0;
|
||||
}
|
||||
const localUrl = `http://localhost:${o.port}`;
|
||||
|
||||
const before = await probePort(o.port, o.host);
|
||||
if (before.state === 'ours') {
|
||||
console.log(`${mark.ok} 服务器已经在运行(端口 ${o.port}),直接打开浏览器。`);
|
||||
printShare(o.port);
|
||||
if (o.open) openBrowser(localUrl);
|
||||
return 0;
|
||||
}
|
||||
if (before.state !== 'free') {
|
||||
console.error(`${mark.err} 端口 ${o.port} 被其他程序占用或无权限(${before.code || before.state})。`);
|
||||
console.error(` 换一个端口:${IS_WIN ? 'scripts\\start-windows.bat --port 3001' : 'scripts/start.sh --port 3001'}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (o.setup) {
|
||||
const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'setup.mjs'), '--quiet', ...o.setupArgs], { cwd: ROOT, stdio: 'inherit' });
|
||||
if (r.status !== 0) { console.error(`${mark.err} 准备步骤失败(见上方)。`); return r.status || 1; }
|
||||
}
|
||||
|
||||
const env = { ...process.env, PORT: String(o.port), HOST: o.host };
|
||||
const child = spawn(process.execPath, [path.join(ROOT, 'server', 'index.js')], { cwd: ROOT, env, stdio: 'inherit' });
|
||||
const forward = (sig) => { if (child.exitCode === null) { try { child.kill(sig); } catch { /* gone */ } } };
|
||||
// SIGINT reaches the server straight from the terminal (same process group / console); forwarding it too would make
|
||||
// the server's second-signal path force-exit. Other signals (service managers, `kill`) are forwarded.
|
||||
process.on('SIGINT', () => {});
|
||||
for (const sig of ['SIGTERM', 'SIGHUP', 'SIGBREAK']) { try { process.on(sig, () => forward(sig === 'SIGBREAK' ? 'SIGTERM' : sig)); } catch { /* unsupported here */ } }
|
||||
|
||||
const exited = new Promise((resolve) => child.on('exit', (code, signal) => resolve(code ?? (signal ? 0 : 1))));
|
||||
if (await waitHealthy(o.port, child)) {
|
||||
printShare(o.port);
|
||||
if (o.open && !openBrowser(localUrl)) console.log(c.dim(`(未能自动打开浏览器,请手动访问 ${localUrl})`));
|
||||
}
|
||||
return exited;
|
||||
}
|
||||
|
||||
main().then((code) => { process.exitCode = code; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user