◈ 夜宵吧

六款游戏,和一个假存档

站上放了六款开源放置游戏。不是嵌外站链接,是把源码拉下来自己托管——断网也能玩,存档还能跟人走。

听着像搬文件,实际每一层都有坑:路径、安全策略、连接数、外站依赖,以及一个「看起来能用、其实会静默丢档」的存储方案。

本文按这六条逐条展开,每条都给出具体配置和它带来的副作用。

六款

游戏 体积 许可
A Dark Room(小黑屋) 11 MB MPL-2.0
Trimps(放置细胞) 6.3 MB GPL-2.0
Progress Knight(转生骑士) 116 KB Unlicense
Kittens Game 14 MB Wet Paws(个人/教育)
Space Company(太空公司) 3.4 MB MIT
Shark Game(鲨鱼世界) 9.3 MB MIT

挑的标准很实际:纯静态、有 index.html、不需要编译。1 GB 内存的机器上跑打包流程是给自己找事。

被否掉的几个也值得记:一个缺 dist/out.js(要 TypeScript 编译)、一个用 vue-cli(要 webpack)、一个 index.html 直接引用 unpkg 上的 jQuery/Vue/Chart.js(等于把命交给外网)、还有一个的 localStorage 键没有前缀且是动态拼的(云端存档会串档)。

第一条:不能放进公开目录

直觉是丢进公开的静态目录,反正就是静态文件。但那样会撞两件事:

  • 站点是单页应用,静态目录兜底逻辑是"找不到就回退到 index.html",游戏里的相对路径会被这条规则吃掉;
  • 更重要的是权限。一开始只打算给自己玩,所以把它放在后台、访问要管理员身份;后来想通了——放着游戏却不给人玩没道理,就挪到了顶栏,谁都能进。

于是给它单独一个目录(不在站点的公开目录里),和一个专门的响应函数:

async function serveGame(req, res, urlPath, me) {
  // 开放给所有人;边界只剩一条:路径必须落在 GAMES_DIR 里面
  ...
}

为什么当时是 404 不是 403:403 等于告诉对方"这里有个东西,你只是没权限",是给扫描器递情报。对不存在的路径和"存在但你不能看"返回同一个页面,才不泄露。(现在放开了,但这条规则留在别处——后台那些页面还是这么处理的。)

路径处理按顺序四件事:解码 → 目录补 index.html → normalize(join(GAMES_DIR, rel)) 后必须还在 GAMES_DIR 里(挡 ..%2f)→ 按 MIME 发。

第二条:结尾的斜杠不是小事

/g/adarkroom 和 /g/adarkroom/ 在浏览器眼里是两个不同的基准路径。

游戏里写的是 <script src="js/main.js">(相对路径)。没有结尾斜杠时,浏览器会把它解析成上一层的 js/main.js——差一层目录,全部 404。

所以入口地址必须带斜杠,目录请求自动补上,并在必要时给出跳转。

第三条:内容安全策略得按路由放宽

主站的 CSP 是 script-src 'self',很干净。但这些游戏全是十几年前写的静态页:内联脚本、onclick 属性、eval。

如果为了它们把全站放宽,等于把主站的安全等级降到游戏的水平。所以按路径拆:

location ^~ /g/ {
    limit_conn perip 120;
    add_header Content-Security-Policy
        "script-src 'self' 'unsafe-inline' 'unsafe-eval'; …";
    include proxy-params.conf;   # 反向代理的公共参数
}

有一个必须知道的 nginx 规则:只要在一个 location 里写了 add_header,父层的同名指令就全部失效。所以那份 snippet 不能只写差异项,得把 HSTS、X-Content-Type-Options、Referrer-Policy 整套抄一遍。

第四条:连接数

一开始沿用主站的 limit_conn perip 20。结果 A Dark Room 一开局就挂了:它把 86 个 .flac 音效写在启动逻辑里,并发拉。

nginx 在 HTTP/2 下按 stream 计数,20 的上限直接 429。给这个前缀单独提到 120,并且不加 limit_req——游戏内部的轮询请求密度比正常浏览高得多,按普通页面限速会误伤。

顺带补了一批缺失的 MIME:.flac、.ogg、.wasm、.woff、.ttf。不补的话浏览器会把它们当二进制流,游戏没声音、字体也回退。

第五条:外站依赖要一个个拆

三处必须处理:

  • A Dark Room 的 jQuery 从 Google CDN 换成 lib/jquery.min.js,顺便删掉 document.write 的兜底死代码;
  • Progress Knight 引了 w3schools 的 w3.css,那个地址现在返回 Access Denied。自己写了个 40 行的 w3-lite.css,只实现用到的 12 个类——注意原版是浅灰底的按钮,而游戏是暗色主题,照抄会变成白底白字;
  • 剩下的外站统计脚本、CDN 图片,被 CSP 挡掉正好。
存档往返:游戏只写 localStorage,存档桥拦截读写并批量上行到存档接口,接口覆盖写入存档表并在写前留一份历史版本
游戏一行都没改。桥在中间,读写在它那里被截住,攒够了再上行。

第六条:存档是最麻烦的

目标很简单:玩到一半换台设备,进度还在。

但"拦截 localStorage 写入"这个方案直接失败。原因是我先假设所有游戏都用 localStorage.setItem()——实际上:

  • A Dark Room 用 localStorage.gameState = ...(属性赋值)
  • Kittens Game 用 LCstorage["com.nuclearunicorn.kittengame.savedata"] = ...(一层封装)

属性赋值不经过 setItem,方法级拦截会静默漏掉两款,而且不报错。

改成轮询:每 5 秒读一次这个游戏的白名单键,跟上次的值比对,变了就上传。对 6 个游戏、每款 1 到 4 个键来说,这点开销可以忽略,换来的是"不管它怎么写,我都读得到"。

同步方向靠一个本地标记 __gs_sync_<slug>,记录"本机这份数据对应云端的哪一版":

情况 动作
云端更新,本地标记更旧 还原(先清空白名单键再写,避免新旧混合)
本地有改动 上传
两边都空 用云端的开局
本地为空但云端有 用云端的开局
本地为空、云端也有 不上传 —— 防止把云端好档冲成空档

最后一条是踩出来的:有次浏览器刚打开、游戏还没读档,5 秒轮询就把"空"同步上去了,云端直接清零。

关页面时的最后一笔用 navigator.sendBeacon——fetch 在 unload 里可能被杀掉,beacon 是专门为这个场景设计的。

服务端怎么把存档送进去

游戏加载时还不知道云端有什么,等它初始化完再注入就来不及了。所以在 HTML 里插两段:

<script>window.__GS__ = {"slug":"trimp","updatedAt":1792...,"data":{...}}</script>
<script src="/_save.js"></script>

JSON 里的 </ 全部转成 <\/,否则遇到正文含 </script> 会把标签提前闭合。

落库两张表:game_saves 存当前档,game_saves_history 留最近 5 份。内容没变时返回 unchanged: true 直接跳过——放置游戏几分钟自动存一次,不做这层判断,一天就是几千条无意义写入。

现在

每款游戏下面有一行小字:x 分钟前同步 · 大小 · 同步 n 次,带"历史"和"清空"。

写这篇文章时 Kittens Game 的存档是 48,970 字节、同步过 105 次——说明这条链路是真的在用,不是演示。

代价

四十几兆静态文件。 六款合计 44 MB,最重的是一款靠八十六个音频文件撑气氛的文字游戏,开局就并发拉。所以给游戏目录单独放宽了连接数上限——这是唯一一处为单个功能改网关参数的地方。

带宽是真的。 文件从服务器出,一份就是一份。已经限了速率和并发,但那只能防止把机器打挂,防不了刷流量。真要防得按 IP 限额,没做。

存档跟着这台机器。 浏览器本地存储是主,云同步是补充:清掉站点数据,本地档就没了,只能指望云端那几份历史。

还有一条隐形的:这些游戏是十多年前的前端代码,安全策略必须为它们放宽。放宽的边界是路由——只有游戏目录下的页面享受宽松策略,主站仍然只认自己的脚本。