六款游戏,和一个假存档
站上放了六款开源放置游戏。不是嵌外站链接,是把源码拉下来自己托管——断网也能玩,存档还能跟人走。
听着像搬文件,实际每一层都有坑:路径、安全策略、连接数、外站依赖,以及一个「看起来能用、其实会静默丢档」的存储方案。
本文按这六条逐条展开,每条都给出具体配置和它带来的副作用。
六款
| 游戏 | 体积 | 许可 |
|---|---|---|
| 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.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 限额,没做。
存档跟着这台机器。 浏览器本地存储是主,云同步是补充:清掉站点数据,本地档就没了,只能指望云端那几份历史。
还有一条隐形的:这些游戏是十多年前的前端代码,安全策略必须为它们放宽。放宽的边界是路由——只有游戏目录下的页面享受宽松策略,主站仍然只认自己的脚本。