# 部署指南 目标:在一台家用 Windows 小主机上长期开服,让朋友通过局域网或公网来玩。macOS / Linux / Docker 放在后面。 所有命令都在项目根目录执行。遇到问题先运行 `node tools/doctor.mjs`(只读诊断)。 ## 0. 资源需求 | 项目 | 说明 | |---|---| | 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 | | 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 | | 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测;0.2.6 起 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,实测一局单人对局的服务器消息从 173 KB 降到 60 KB,约为原来的三分之一)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。0.2.6 起服务器启动后在后台给素材算内容哈希(第一次约 1–20 秒,看机器和硬盘;之后只检查大小和修改时间),下发的素材清单里每个地址带上哈希:浏览器把图片、模型和音频缓存一年,服务器更新、重新解压或 Docker 重建之后,玩家只重新下载内容真的变了的文件。哈希存在 `.cache/asset-hashes.json`,删掉会重新计算;目录不可写(例如只读的容器)时只在内存里算。 | | 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 790 MB。 | | 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 | 服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。唯一例外是打开可选的森空岛账号绑定(`SP_SKLAND=1`,第 3.2 节):它的凭据库需要持久化和备份。 ## 1. Windows 小主机:一步步 ### 1.1 安装与首次启动 1. 安装 Node.js 22 LTS 和 Git(在 PowerShell 或「终端」里;用下面的整合包时不需要 Git): ```powershell winget install OpenJS.NodeJS.LTS winget install Git.Git ``` 装完**关闭并重新打开**终端,`node -v` 应显示 v22 或更高(winget 的 LTS 目前是 v24.x,同样可用)。没有 winget 时从 和 下载安装。 2. 下载,三选一。建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如 `C:\Stronghold-Protocol`: - **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 560 MB,解压后约 790 MB;已含运行依赖、前端库和全部素材,包括中文、日文两套干员语音和官方 3D 棋盘、各战场官方场景等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。 - **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频(含两套干员语音)、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 550 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。 - **源码**: ```powershell git clone https://github.com/sganggs/Stronghold-Protocol.git C:\Stronghold-Protocol ``` 3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;整合包已含,跳过)→ 复制前端库(整合包已含,跳过)→ 下载约 550 MB 素材(完整包已含,跳过;精简包和源码在这一步下载,显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。 4. 窗口里会打印朋友可用的地址,例如 `http://192.168.1.23:3000`。用另一台设备打开它确认能进入。关闭窗口即停止服务器。 等价的手动命令:`npm ci`、`node tools/setup.mjs`、`npm start`。 #### 国内镜像下载 Setup 默认使用「GitHub 原始源 → jsDelivr」,不查询公网 IP,也不请求 gh-proxy.com。GitHub 下载失败时会提示如何手动开启镜像;仅添加提示,不自动切换到第三方代理。 镜像方法是在完整 GitHub 链接前加 `https://gh-proxy.com/`,例如: ```text https://gh-proxy.com/https://raw.githubusercontent.com/OWNER/REPO/BRANCH/file.png ``` 手动开启后顺序为「前缀镜像 → 原始源 → jsDelivr」。索引、图片、Spine、音频和字体都使用此规则(音频 voice 分支跳过 jsDelivr)。镜像是第三方代理;当前只校验格式和大小,没有内容哈希校验,请自行决定是否信任并启用。npm / pip 依赖不使用 GitHub 前缀。 ```powershell node tools/setup.mjs --asset-source=mirror # 手动优先国内镜像 node tools/setup.mjs --asset-source=direct # 默认:仅原始源和 jsDelivr,不使用前缀代理 $env:SP_ASSET_SOURCE = 'mirror' # 也可用环境变量显式启用 ``` `node tools/fetch-assets.mjs` 同样支持 `--asset-source=direct|mirror`。命令行优先于 `SP_ASSET_SOURCE`。默认镜像前缀为 `https://gh-proxy.com/`,可通过 `SP_GITHUB_PROXY` 指定其他 HTTPS 前缀;仅配置前缀不会启用镜像。将 `SP_GITHUB_PROXY` 设为空字符串(或全空格)可彻底禁用前缀代理,即使选择了 `mirror` 模式;未设置此变量与显式设空不同,前者使用默认前缀。Windows PowerShell 的某些版本会将空值视为删除变量,可设置 `$env:SP_GITHUB_PROXY = ' '` 或使用 `--asset-source=direct` 来明确禁用。前缀只处理 GitHub 下载链接,不重复添加。 镜像请求每个 URL 只尝试一次,响应头超时 8 秒,响应体有独立的空闲超时,失败即尝试原始源。连续 3 次网络错误、HTTP 错误或无效内容会在本次运行中关闭镜像,后续索引、素材和字体共享该状态;正在进行的镜像请求也会中止并回退。成功会清零连续失败次数;404 / 410 是资源不存在,不触发熔断。再次运行脚本会重新尝试手动启用的镜像。原始源的重试、已有文件跳过和 0.1.1 的清单缩减保护保持不变。 从历史下载记录派生的 Spine 补充贴图也按本次设置重新选择来源,禁用后不会沿用旧代理地址。 ### 1.2 防火墙 - 第一次启动时 Windows 会弹出「Windows 安全中心警报」:勾选**专用网络**并点「允许访问」。 - 没弹窗或点错了,用**管理员** PowerShell 添加规则(下面的开机自启脚本也会自动添加): ```powershell netsh advfirewall firewall add rule name="Stronghold Protocol" dir=in action=allow protocol=TCP localport=3000 profile=private,domain ``` - 家里的网络要是「公用网络」,Windows 会拦截入站连接。改成专用(管理员 PowerShell;网卡名用 `Get-NetConnectionProfile` 查看): ```powershell Set-NetConnectionProfile -InterfaceAlias "以太网" -NetworkCategory Private ``` - `node tools/doctor.mjs` 会显示规则是否存在、每个网络的类型,以及朋友可用的地址。 ### 1.3 固定局域网 IP(推荐) 主机 IP 变了,朋友收藏的地址就失效。推荐在**路由器**后台的「DHCP 静态分配 / 地址保留」里把小主机的 MAC 地址绑定到固定 IP(如 `192.168.1.50`)。也可以在 Windows「设置 → 网络和 Internet → 属性 → IP 分配 → 编辑」里手动设置(IP、子网掩码、网关、DNS 与路由器一致,且不要与别的设备冲突)。 ### 1.4 开机自动在后台运行 先关闭 `start-windows.bat` 的窗口(否则端口冲突),然后在项目目录运行(会自动请求管理员权限): ```powershell powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 ``` 它会:运行一次 `tools/setup.mjs` → 把设置写入 `scripts\service.env.cmd`(node.exe 路径、端口等)→ 注册计划任务 **StrongholdProtocol**(开机 20 秒后以 SYSTEM 身份运行 `scripts\run-server.cmd`,无需登录;服务器退出后 5 秒自动重启)→ 添加防火墙规则 → 立即启动并显示状态。日志在 `logs\server.log`(超过 10 MB 自动轮换)。 | 需求 | 命令(都加在 `powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1` 之后) | |---|---| | 换端口 / 其他设置 | `-Port 8080`、`-Verify sample`、`-Combat server`、`-BindHost 127.0.0.1`(只给反向代理用) | | 公用网络也放行 | `-AllowPublicNetwork`(一般不需要;Tailscale 网卡被识别为公用网络时可能需要) | | 查看状态和最近日志 | `-Status` | | 重启(更新代码后) | `-Restart` | | 停止 | `-Stop`(下次开机仍会自动启动) | | 卸载 | `-Uninstall`(删除计划任务、防火墙规则和 `service.env.cmd`) | 建议同时关闭睡眠,否则小主机会在无人操作时休眠:`powercfg /change standby-timeout-ac 0`。
替代方案:用 NSSM 注册成真正的 Windows 服务 ```powershell winget install NSSM.NSSM # 或从 https://nssm.cc 下载 nssm install StrongholdProtocol "C:\Program Files\nodejs\node.exe" server\index.js nssm set StrongholdProtocol AppDirectory C:\Stronghold-Protocol nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=:: nssm set StrongholdProtocol AppStdout C:\Stronghold-Protocol\logs\server.log nssm set StrongholdProtocol AppStderr C:\Stronghold-Protocol\logs\server.log nssm start StrongholdProtocol ``` 防火墙规则仍需按 1.2 手动添加。两种方式只选一种。
### 1.5 更新 ```powershell cd C:\Stronghold-Protocol powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Stop # 装了开机自启时 git checkout -- data/assets.json # 素材清单由 setup 重新生成,先还原以免 git pull 冲突 git pull npm ci node tools/setup.mjs # 补下载新增的素材(已有文件会跳过) powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Restart ``` 没装开机自启的话,最后一步改成重新双击 `start-windows.bat`。 #### 用整合包安装的:更新包 0.2.1 起,Releases 里每个版本除了完整包和精简包,还有更新包 `Stronghold-Protocol-v<版本>-update.zip`:只含比之前的 0.2.x 版本改动过的文件(程序、数据、运行依赖,以及改动过的素材),通常只有几 MB。它用来升级用 0.2.0 及以后的完整包或精简包装好的文件夹(适用的版本写在 Releases 说明里);全新安装、0.1.x 和 GitHub「Download ZIP」源码包请用完整包或精简包,`git clone` 的用上面的 `git pull`。 ```powershell cd C:\Stronghold-Protocol powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Stop # 装了开机自启时;没装就关掉服务器窗口 Expand-Archive -Force <下载目录>\Stronghold-Protocol-v<版本>-update.zip C:\ # 解压到安装文件夹的上一层,合并进 C:\Stronghold-Protocol、覆盖同名文件 powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Restart ``` 没装开机自启的,最后一步改成双击 `start-windows.bat`。也可以在资源管理器里打开 zip,把其中 `Stronghold-Protocol` 文件夹的全部内容复制到安装文件夹,选「替换目标中的文件」。macOS / Linux:停止服务器后运行 `unzip -o Stronghold-Protocol-v<版本>-update.zip -d <安装文件夹的上一层>`(安装文件夹名为 `Stronghold-Protocol`);不要用访达拖放,它会把同名文件夹整个替换掉。 启动时(双击启动、`npm start`、开机自启的计划任务、NSSM / systemd 都一样)服务器先完成更新:按 `MANIFEST.json` 核对全部程序文件(代码、数据、运行依赖、前端库和说明文档;素材归 setup 管,不在其中),删除 `UPDATE.json` 列出的、新版本不再使用的旧文件(只删内容与旧版本发布时完全一样的文件:自己改过的文件、自己装的内容包、`logs`、`.cache` 都不会动),再把 `UPDATE.json` 改名为 `.update-applied.json`,然后照常启动,日志里有一行「已更新到 v<版本>」。 - **文件夹不是更新包对应的版本**(例如 0.1.x、只解压了一部分、程序文件被改过):服务器**不启动**,提示「这个更新包只能覆盖在 v0.2.0 … 的整合包安装上(检测到 N 个文件与 v<版本> 不一致或缺失)」并举出几个文件(开机自启的,`-Status` 显示的日志里能看到)。新旧文件混在一起运行,容易出现看起来像游戏 bug 的错误,所以宁可不启动。下载完整包重新安装,或把更新包重新完整解压一遍再启动(`UPDATE.json` 还在,会重新检查;在此之前什么都不删)。 - 只有说明文档、`scripts\`、`tools\` 里的文件不一致:照常启动,日志里列出是哪些文件。`UPDATE.json` 损坏:跳过并提示,按现有文件启动。 - **精简包装的也能用**:程序部分与完整包完全相同;更新包带着完整包里改动过的素材,其余素材仍由 setup 补齐。如果更新包带来了新的本地客户端素材清单(`data/local-assets.json`)而这台电脑没有对应的素材,启动时会提示:从同一版本的完整包复制 `public/assets/local/`(第 6 节),或运行 `node tools/setup.mjs --local` 提取,不需要时删掉 `data/local-assets.json` 即可。 - 更新包不联网、不会自动更新,和完整包一样从 Releases 手动下载。`npm run doctor` 的「文件校验 MANIFEST.json」一行随时显示全部程序文件是否与这个版本一致(源码目录没有这个文件,不做校验)。 不用更新包也可以:停止服务器,把新版本的整合包解压到新目录后从那里启动(完整包已含素材;装了开机自启的,在新目录重新运行一次 `install-service-windows.ps1`)。用精简包或 GitHub「Download ZIP」源码包的:解压新版本后,把旧目录里的 `public\assets`、`public\fonts`、`.cache` 和 `data\local-assets.json`(若有)复制过去,可避免重新下载(setup 只补下新增的素材)。 ## 2. 让不在同一网络的朋友加入 ### 2.1 Tailscale / ZeroTier(推荐给家用小主机) 组一个虚拟局域网:不需要公网 IP、不需要改路由器、不暴露到互联网。 - **Tailscale**:主机和朋友都安装 (Windows:`winget install Tailscale.Tailscale`)并登录。朋友用自己的账号时,在 Tailscale 管理后台把这台主机「Share」给他们,或邀请他们加入你的 tailnet。朋友访问 `http://<主机的 100.x.y.z 地址>:3000`(`tailscale ip -4` 查看;开了 MagicDNS 也可以用 `http://<主机名>:3000`)。 - **ZeroTier**:在 创建网络,主机和朋友安装客户端并加入同一个 Network ID,在后台勾选授权成员;访问 `http://<主机的 ZeroTier IP>:3000`。 - 连不上时运行 `node tools/doctor.mjs`:看 VPN 网卡是否被 Windows 识别为「公用网络」,是的话按 1.2 改为专用,或安装自启时加 `-AllowPublicNetwork`。 ### 2.2 cloudflared 临时隧道(朋友什么都不用装) ```powershell winget install --id Cloudflare.cloudflared # macOS: brew install cloudflared cloudflared tunnel --url http://localhost:3000 ``` 把输出的 `https://xxxx.trycloudflare.com` 发给朋友。页面是 https 时客户端自动改用 `wss://`,不需要任何配置;服务器会通过隧道转发的 `CF-Connecting-IP` 识别真实来源(`TRUST_PROXY=auto`)。临时隧道每次启动地址都不同,且没有可用性保证;需要固定地址请使用 Cloudflare 账号 + 自己域名的「命名隧道」。 **固定访问地址能省很多流量**:浏览器的缓存、设置、统计和断线令牌都按地址(域名或 IP + 端口)分开保存。地址一换,对玩家来说就是一个新网站:每个人进游戏都要把图片、模型和音频重新下载一遍(第一局约 20–50 MB),设置和本机统计也看不到了。临时隧道每次重启都换域名,所以固定朋友圈子长期开服时,建议用命名隧道、Tailscale(2.1)或固定的公网 IP / 局域网 IP(1.3),让大家一直用同一个地址。 ### 2.3 路由器端口转发 仅当你有**公网 IPv4**(很多宽带是运营商级 NAT,没有公网 IP,此时请用 2.1 / 2.2): 1. 先按 1.3 固定主机的局域网 IP。 2. 路由器「虚拟服务器 / 端口转发」:外部端口 3000(或任意端口)→ 内部 `主机IP:3000`,TCP。 3. 朋友访问 `http://<你的公网 IP>:外部端口`。 本分支的标题入口要求绑定账号(第 3.2 节);启用模块后,服务器也会校验房间/对局操作对应的账号角色。服务器对来自互联网的连接有按网络的数量限制(每个网络最多 64 个连接,房间 / 对局数量也有上限),建议不玩时关掉转发,或优先用 Tailscale。公网账号登录使用 HTTPS(2.4)。 ### 2.4 反向代理与 HTTPS(有域名时) 必须部署在**域名根路径**(客户端使用 `/data/`、`/vendor/`、`/ws` 等绝对路径,不支持挂在子路径下)。代理需要转发 WebSocket 升级(路径 `/ws`)。建议让服务器只监听本机:`HOST=127.0.0.1`(Windows 自启:`-BindHost 127.0.0.1`)。 **Caddy**(自动申请 HTTPS 证书,WebSocket 无需额外配置): ```caddy game.example.com { reverse_proxy 127.0.0.1:3000 } ``` **Nginx**: ```nginx map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl http2; # 新版 Nginx(1.25.1 起)写成 listen 443 ssl; 加一行 http2 on; server_name game.example.com; ssl_certificate /etc/letsencrypt/live/game.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/game.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 1h; # WebSocket 长连接 } } ``` **HTTP/2**:客户端由数百个小脚本模块组成(0.2.0 起进入对局时约 340 个),隔着公网时建议让代理以 HTTP/2 提供页面:所有模块走同一条连接,远距离玩家首次进入对局明显更快。Caddy 默认就是 HTTP/2;Nginx 见上面的 `http2`。服务器本身只说 HTTP/1.1,局域网或本机游玩不受影响。HTTP/2 省的是往返等待,不减少字节数。 **压缩**:服务器自己压缩脚本、样式、数据和 Spine 的 `.skel` / `.atlas`:浏览器支持 brotli 时用 brotli(0.2.6 起,质量 5,比 gzip 快且更小:实测 `.skel` 小约 27%、游戏数据 JSON 小约 26%、前端库小约 14%、脚本小约 5%),否则用 gzip;图片和音频本来就是压缩格式,原样发送。浏览器只在 HTTPS 页面(以及 `localhost`)上声明支持 brotli,所以通过代理或隧道的 https 地址访问时用 brotli,局域网里的 `http://192.168.x.x:3000` 这类地址用 gzip。代理不需要另开压缩(Caddy 的 `encode`、Nginx 的 `gzip` 遇到已经压缩的响应会跳过)。 **Cloudflare 缓存**(命名隧道或自己的域名经过 Cloudflare 时):Cloudflare 默认只缓存常见扩展名,`.skel`、`.atlas` 和没有扩展名的音频地址 `/media/…` 都不在其中,每个请求都会回到开服的电脑。在 Cloudflare 控制台的「缓存规则」里加一条:主机名是你的游戏域名、且 URI 路径以 `/assets/`、`/fonts/` 或 `/media/` 开头时「符合缓存条件」,边缘 TTL 选「使用源站的 Cache-Control」。0.2.6 起素材清单里的地址带内容哈希(`/assets/~<哈希>/…`),服务器给这类地址一年的 `immutable`,换了内容的文件会换地址,所以长期缓存是安全的;`/data/`、页面和脚本不要加进规则(它们要随服务器更新)。注意:边缘公开缓存官方素材会增加被投诉的风险,大量媒体文件也要留意 Cloudflare 的服务条款;只给自己人玩时不加这条规则也可以。 https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://同一域名/ws`;http 时用 `ws://`。服务器本身只提供 http,证书由代理 / 隧道负责。代理与服务器在同一台机器或内网时,`TRUST_PROXY=auto` 会信任它的 `X-Forwarded-For` / `X-Real-IP`;代理在公网另一台机器上时设 `TRUST_PROXY=1`(同时确保游戏端口只对代理开放)。 ## 3. Docker ### 3.1 一条命令(docker compose) 仓库根目录的 `docker-compose.yml` 已经把构建、端口、重启策略和数据卷写好,在项目根目录执行: ```bash docker compose up -d # 构建镜像并后台启动(首次会构建;HTTP 服务在 http://<主机>:3000) docker compose logs -f # 跟随日志;docker compose ps 看状态 docker compose down # 停止并删除容器(数据卷 skland-data 保留) ``` 不用 compose 时等价的原始命令(`FETCH_ASSETS=1` 在构建时下载素材,约 550 MB,需要联网;不下载则用占位图,或改用 B 挂载宿主机素材): ```bash # A) 构建时下载素材 docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 . docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol # B) 不把素材打进镜像:先在宿主机运行 node tools/setup.mjs,然后挂载 docker build -t stronghold-protocol . docker run -d --name stronghold -p 3000:3000 --restart unless-stopped \ -v "$PWD/public/assets:/app/public/assets:ro" stronghold-protocol ``` 镜像从源码(`git clone`)构建,Releases 的整合包不含 `Dockerfile`,也不含 `docker-compose.yml`。镜像基于 `node:22-alpine`,多阶段构建,只含生产依赖;`public/vendor` 在构建时生成。`.dockerignore` 排除了 `public/assets`(不会把宿主机素材打进构建上下文);`public/fonts`、`data/assets.json` 和 `data/local-assets.json` 若存在会被复制进去。环境变量同 README(`-e SP_VERIFY=sample` 等),另有第 3.2 节的森空岛两项。健康检查:`GET /healthz`。 ### 3.2 森空岛账号绑定与数据卷 可选的**账号绑定**(`server/skland/`,见 [skland.md](skland.md))把账号凭据、JWT 签名密钥和可撤销的登录记录存进同一个 SQLite 文件;这是服务器需要跨重启保留的账号数据(房间和对局仍然只在内存里,第 0 节)。JWT 以 HttpOnly Cookie 保存 30 天,刷新、重新开页面或重启服务不用重新绑定;解绑会使旧 JWT 失效。不设置 `SP_SKLAND` 时模块不启用、不建库,`/skland/*` 统一返回 JSON 404;本分支的标题入口仍要求绑定,因此开服需要启用它。需要含 `node:sqlite` 的 Node 22.5 或更新版本。 | 环境变量 | 默认 | 说明 | |---|---|---| | `SP_SKLAND` | 空(关闭) | 设为 `1` 时打开账号绑定模块 | | `SP_SKLAND_DB` | `data/skland.db` | SQLite 凭据库的路径(相对路径相对项目根目录);父目录不存在时自己创建 | | `SP_AUTH_SECRET` | 自动生成并存入数据库 | 可选的 JWT 签名密钥,至少 32 字节;不要每次启动随机改变,轮换会使现有登录失效 | | `SP_PUBLIC_ORIGIN` | 按请求来源判定 | 可选的固定访问地址,例如 `https://game.example.com`,用于同源校验和 HTTPS Cookie | | `SP_SKLAND_DIAGNOSTIC_KEY` | 空(关闭) | 仅管理员诊断接口使用;调用需 `X-SP-Diagnostic-Key`,不要填进前端或发给玩家 | `docker-compose.yml` 里这两个变量都指向挂载的具名卷: ```yaml environment: SP_SKLAND: "1" SP_SKLAND_DB: /app/skland-data/skland.db volumes: - skland-data:/app/skland-data ``` - **为什么用卷**:容器以 `USER node` 运行,删除容器、`docker compose down` 或重建镜像都会丢掉镜像层里的文件;数据库放在具名卷 `skland-data` 上,重启、`docker compose down` 和 `docker compose up --build` 之后绑定关系都还在。`docker compose down -v`(或 `docker volume rm`)才会删除它。 - **为什么用具名卷而不是绑定挂载**:Windows / macOS / Linux 上行为一致,不需要先建宿主机目录、也不用管容器内 `node`(uid 1000)对宿主机目录的权限。要在宿主机上直接读这个文件时,把挂载换成 `- ./skland-data:/app/skland-data`(目录已在 `.gitignore` 里)。 - **关掉模块**:把 `SP_SKLAND` 和 `SP_SKLAND_DB` 两行删掉或注释掉即可(卷可以留着不用),服务器就不再保存任何账号凭据。手工 `docker run` 时对应地去掉 `-e SP_SKLAND=1 -e SP_SKLAND_DB=…` 两个参数。 - **改用别的路径**:`SP_SKLAND_DB` 换成卷里的其它位置(例如 `/app/skland-data/skland-2.db`)。内置静态服务会拒绝下载当前配置的库及 WAL/SHM 文件,但仍建议放在所有静态目录之外,避免其它文件服务器或代理规则泄露。 公网使用 HTTPS;代理要保留 Host 并传递 `X-Forwarded-Proto`(2.4),也可显式设置 `SP_PUBLIC_ORIGIN`。 HTTPS 下 Cookie 带 Secure;本地 `127.0.0.1` 开发使用 HTTP。游戏内用角色 UID + 区服标识账号,昵称可以重复。 登录记录不保存密码,JWT 也不携带鹰角/森空岛凭据。 **随账号跨设备恢复**:新设备登录同一账号并选择同一区服角色后,可在标题页点击「恢复账号对局」,无需复制 本机缓存。查询对局或自动恢复登录不会挤掉旧设备;点击「开始」或确认恢复后才接续原席位,旧设备断开且不会 自动抢回连接。恢复仍受原有断线期限限制(独立模拟 24 小时、同盟对局 10 分钟,等候房间另受 60 秒宽限期限制), **不包含服务器重启后的对局存档**。只会查询当前角色,昵称相同或其他区服不会串号。 > **这个数据库是凭据,按密钥对待。** `skland.db` 里是明文保存的 `access_token`、长期 `cred` 和签名密钥 `cred_token`,拿到文件等于拿到对应账号的接口权限(skland.md §9): > - **限制权限**:只让运行服务器的账号可读,例如 `chmod 700 skland-data && chmod 600 skland-data/skland.db*`(WAL 模式下还会有 `-wal` / `-shm` 两个附属文件,同样处理)。在宿主机上运行而非容器里时默认路径是 `data/skland.db`,权限同理。 > - **绝不通过 HTTP 暴露**:卷挂在 `/app/skland-data`,不在 `public/`、`data/`、`shared/` 任何静态挂载之下;反向代理也不要为它开规则。 > - **不提交、不进镜像**:`.gitignore` 忽略了 `data/skland.db`、`-wal` / `-shm` 和 `skland-data/`;构建上下文只会把 `data/` 复制进镜像,所以容器部署要把库放在 `data/` 之外(compose 已经这么做)。 > - **备份要当密码处理**:`docker compose stop` 后再从卷里拷贝(WAL 写入中直接拷可能拿到不一致的副本),备份文件加密保存、限权,不要放进网盘 / 仓库 / 聊天记录;不再使用时删除卷(`docker compose down -v`)。 ## 4. macOS / Linux 常驻 - 临时开服:`scripts/start.sh`(或 `npm start`),保持终端窗口打开。macOS 首次会询问是否允许 node 接受传入连接,选「允许」。 - Linux systemd(`/etc/systemd/system/stronghold.service`,路径与用户按实际修改): ```ini [Unit] Description=Stronghold Protocol game server After=network-online.target Wants=network-online.target [Service] WorkingDirectory=/opt/Stronghold-Protocol ExecStart=/usr/bin/node server/index.js Environment=PORT=3000 HOST=:: Restart=always RestartSec=5 User=stronghold [Install] WantedBy=multi-user.target ``` `sudo systemctl daemon-reload && sudo systemctl enable --now stronghold`;日志 `journalctl -u stronghold -f`;防火墙 `sudo ufw allow 3000/tcp`。 ## 5. 排错 | 现象 | 处理 | |---|---| | 任何问题 | `node tools/doctor.mjs`:Node 版本、依赖、素材完整性、端口、局域网地址、防火墙、网络类型 | | `端口已被占用 / EADDRINUSE` | 已经有一个服务器在运行(自启任务?)或其他程序占用 3000:换端口 `scripts\start-windows.bat --port 3001` | | 朋友打不开页面 | 防火墙规则 / 网络类型(1.2);确认用的是 `LAN` 地址而不是 `localhost`;访客 Wi-Fi 常开启「AP 隔离」;不在同一网络请看第 2 节 | | 画面是占位图、没有声音 | 素材没下完:重新运行 `node tools/setup.mjs`(会续传);缺失明细在 `.cache/assets-report.json`。默认仅原始源和 jsDelivr;可用 `--asset-source=mirror` 手动开启前缀镜像(见上文) | | 素材下载很慢 / 失败 | 网络问题可随时中断,重新运行会跳过已完成的文件;`node tools/fetch-assets.mjs --concurrency=4` 降低并发。有文件没下载成功时,素材清单 `data/assets.json` 保持不变(脚本列出缺少的条目并以非零状态结束;游戏里缺的图片用占位图,缺的声音不播放),重新运行即可补齐 | | 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` | | 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` | | 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) | | 素材地址里的 `~` 加一串字母数字 | `/assets/~<哈希>/…` 是 0.2.6 起的带版本地址(见第 0 节「网络」),浏览器可以长期缓存,属于正常现象。怀疑它导致问题时,可以用 `SP_ASSET_INDEX=0` 启动服务器临时关闭(清单改回普通地址),并请在 issue 里说明 | | 想知道开服电脑上传了多少流量 | 浏览器打开 `http://localhost:3000/healthz?traffic=1`(换成你的端口):`traffic.http` 是服务器启动以来各类内容(`assets` 图片和模型、`audio` 音频、`code` 页面和脚本、`data` 游戏数据和素材清单)发出的字节数和请求数(压缩后的实际字节,`notModified` 是只回了「没变」的 304),`traffic.ws` 是 WebSocket 的线上字节(`wireOut` / `wireIn`,压缩后)和压缩前的消息字节(`payloadOut` / `payloadIn`)。数字只在内存里,重启后从 0 开始;只有本机和局域网(含虚拟局域网地址)的访问能看到,公网访问者(包括经隧道 / 反代转发来的)只拿到普通的 `/healthz`。玩家那边:设置 →「问题反馈」复制的诊断里有一行 `traffic:`,是这个页面从打开起下载的字节数(`cached` 是浏览器缓存直接给的,不占流量;`external` 是其他网站,例如 Google 字体) | | 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 | ## 6. 本地客户端素材(可选) `public/assets/local/` 和 `data/local-assets.json` 是从本机安装的《明日方舟》客户端里提取的官方素材(`tools/local-extract`,DESIGN §13):`node tools/setup.mjs` 检测到客户端时会询问是否提取,之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定客户端目录。setup 从公开镜像下载的素材不包含这部分,所以在没有客户端的电脑上(例如 Linux 服务器)用源码或精简包部署时不会有它;Releases 的完整包里已经带上了。 没有本地素材时游戏照常运行,只是下面几样换成替代样式: | 内容 | 没有本地素材时 | |---|---| | 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 | | 各战场的官方场景(沙地等 11 个战场,0.2.5 素材包;完整包在提取后带上,精简包和源码可在本机下载并提取,见下) | 3D 棋盘用程序生成的金属地块 | | 部分官方界面图标与底板:交流按钮和表情面板的边框、暂停面板、装备替换窗口、干员调配界面、队友状态与漏怪标记、模组类型图标等 | 样式相近的替代图形、图标或文字 | | 灼热 / 炽焰源石虫的官方模型 | 染成橙色 / 红橙色的普通源石虫 | | 39 个召唤物的官方模型(多数自选召唤物,以及凯瑟琳的爬行号·防护单元、凛御银灰的风雪之眼;公开镜像没有) | 召唤物头像(菱形底板) | 表情(6 套 × 6 个)和「玩法说明」的 19 页教程图公开镜像也有:`node tools/setup.mjs` 会和其他素材一起下载(约 21 MB),不需要客户端;有本地素材时优先显示本地的。 **没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的,召唤物模型是 0.2.0 加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。 **0.2.0 之前提取过的**:召唤物模型是 0.2.0 新增的提取项,旧的提取结果里没有(`node tools/setup.mjs` 会提示「缺少新版的自选召唤物模型」)。有客户端的电脑运行 `node tools/setup.mjs --local` 重新提取即可,只想补这一项也可以在提取用的 Python 环境里运行 `tools/local-extract/extract.py --only spine/token`(新文件写入 `public/assets/local/spine/token/`,清单里其他条目保持不变)。 **各战场的官方场景**(0.2.5 素材包,维护者 2026-10-12:可以进完整包;精简包和源码安装可以在本机下载):官方棋盘是每个战场一个做好的场景(11 个战场,其中 9 个是沙地)。场景包只在赛季开放期间随客户端分发,赛季外的客户端里没有。仓库不存放这些官方文件。有客户端的电脑可以 `node tools/setup.mjs --scenes`(询问或强制下载并提取;`--no-scenes` 跳过)。也可以分两步:`python3 tools/local-extract/fetch_scenes.py`(从官方 CDN 下载赛季版本 `26-04-13-12-31-42_e9b560` 的 33 个场景包,共 22,801,308 字节,放进 `.cache/official-scenes/`;`--list` 只看清单不下载),再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(材质和贴图仍取自本机客户端;`--game` 可指定客户端目录)。没有 Python 3.8+,或 UnityPy / lz4 / Pillow 没装好时,setup 只提示并继续,3D 棋盘用金属地块。没有客户端时贴图取不出来,从同一版本的完整包复制 `public/assets/local/` 和 `data/local-assets.json`。作者估计每个场景在进对局时另下载约 2–7 MB,各场景共用贴图约 3 MB;本机没有跑过提取,完整包因此增加的解压体积没有实测。 **3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。 **「玩法说明」教程图的下载量**(0.2.6 起):本地提取的 19 页教程图每页 1.2–1.5 MB,玩家每翻一页都要从开服的电脑下载。提取时同样给每页写一份 WebP(有损,质量 90,放大两倍对比看不出差别),清单列 WebP,PNG 留在旁边;19 页合计从约 25.6 MB 降到约 4.2 MB。看说明时只预载下一页(以前前后两页都预载)。已经提取过的同样用 `tools/local-extract/extract.py --webp` 补上。 ## 7. 打包发布(维护者) Releases 的 zip(完整包、精简包,0.2.1 起还有更新包)由 `tools/package.mjs` 生成,在**源码仓库**里运行(整合包里没有这个工具): ```bash npm run package -- --dry-run --list # 只检查:列出每个文件和大小,不写任何文件(精简包加 --lite) npm run package -- --out <目录> # 完整包 Stronghold-Protocol-v<版本>.zip npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版本>-lite.zip npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip ``` 完整包在打包机已经提取战场场景时带上 `public/assets/local/scene/`(`--dry-run` 摘要里的 `battlefield scenes` 一行)。还没有提取时完整包仍能打,棋盘用金属地块。要带上 11 个官方场景,先运行 `python3 tools/local-extract/fetch_scenes.py`,再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(或 `node tools/setup.mjs --scenes`),然后再 `npm run package`。精简包不带场景。`.cache/official-scenes/` 在拒绝名单里,不会打进任何包。`tools/local-extract/scenes.py` 和 `fetch_scenes.py` 必须先提交:打包读的是 `git ls-files`,未提交的脚本不会进玩家包。 - **打进去的**:`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` 保留打包用的目录供检查。