Skip to content

🧪 Add a tiny first test suite + one-command npm test (3 high-value tests) #6

Description

@longmaolab

TL;DR (English)

First off — this project is genuinely impressive. ~117 weapons, 39 maps, bots with AI, a killcam, a full shop economy with credits/fragments/chests/a spin wheel, AI chat... and you built it solo. That's a real game, not a toy. 👏

Right now there are zero automated tests — confirmed in package.json lines 5-7, the only script is start, and there's no tests/ folder. That's totally normal for a project that grew this fast! This issue proposes a pro habit that'll make it even stronger: a tiny test suite (3 small tests) and a one-command npm test, using only Node's built-in test runner — no new dependencies to install.

The payoff is simple: a test catches a break before your players do. You already had the "production was 100KB behind local" scare (it's even written down in CLAUDE.md gotcha #6). A test that runs in 2 seconds is way cheaper than a player DMing you "the shop let me buy a gun with negative credits lol".


中文详细说明

你已经做得很棒的地方 💪

先说真的 —— 这个项目超出预期地厉害。一个人独立做出来一个有 ~117 把武器、39 张地图、会打架的 bot、击杀回放(killcam)、完整的商店经济系统(credits / fragments / 宝箱 / 转盘)、还有 AI 聊天的浏览器 FPS。很多成年工程师都做不到这个规模。这个 issue 不是说你哪里做错了,而是想给你介绍一个"职业选手的好习惯",让这个游戏更稳。

现状(已核实)

  • package.json 第 5-7 行scripts 里只有 "start": "node server.js",没有 test
  • 项目根目录没有 test/tests/__tests__/ 文件夹。

也就是说:现在每次改 server.js,验证方式只能是手动开服务器、自己点。这没问题,但随着武器/地图越加越多,手测会越来越累,而且容易漏。自动化测试 = 一个永远不偷懒、2 秒就帮你全部点一遍的小助手。

为什么值得(payoff)

CLAUDE.md 里你自己记的 gotcha #6 就是血泪教训:"好几个 bug 其实是 Railway 在跑旧版本 / 页面是从 file:// 打开的"。测试就是用来在玩家发现之前抓住这类"明明本地没事、线上却炸了"的问题。写一次,之后每次改代码跑一下 npm test,绿了再 push,心里就有底。


建议的 3 个高价值测试(一个小朋友也能写出来)

我特意挑了 3 个最容易写错、又最伤玩家体验的地方。每个都对应 server.js 里真实存在的代码。

✅ 测试 1 — 商店经济:买不起就不能买,余额不能变负数

这是最该测的,因为它直接关系到游戏公平性(你很在意 balance!)。买武器的逻辑在 server.js 第 376-381 行

const cost = WEAPON_COSTS[weaponId];
if ((u.credits || 0) < cost) return res.status(402).json({ error: 'not enough credits', credits: u.credits, cost });
u.credits -= cost;
u.purchased.push(weaponId);

要验证的行为:

  • 钱不够时,请求返回 402,并且 credits 没有被扣(不会变成负数)。
  • 钱够时,扣的金额正好等于 WEAPON_COSTS(第 47 行起) 里的价格,比如 ak20: 250
  • 同一把武器买第二次不会重复扣钱(第 375 行already 分支)。

⚠️ 一个真实的小坑:server.js 目前没有 module.exports,而且在 第 1270 行 直接 server.listen(...) 起了服务。所以这个测试最简单的写法是:先开服务器,再用 HTTP 请求去打它(Node 20 自带 fetch,不用装任何东西)。下面的示例就是这么做的。

✅ 测试 2 — 伤害结算:WEAPON_DAMAGE 正确,血量最低到 0(不会变负)

伤害逻辑在 server.js 第 1055-1057 行

let dmg = WEAPON_DAMAGE[data.weapon] || 25;
if (data.headshot) dmg = data.instakill ? target.hp : dmg * 2; // headshot: 2× (or instakill)
target.hp = Math.max(0, target.hp - dmg);

要验证的行为:

  • 用某把武器打一下,掉的血正好等于 WEAPON_DAMAGE(第 812 行起) 里的值,比如 railgun: 110ak20: 25
  • 爆头(headshot)时伤害翻倍。
  • 血量地板是 0:哪怕用 railgun(110) 打一个只剩 10 血的人,hp 也只会是 0,不会是 -100。这正是 Math.max(0, ...) 在保护你 —— 测试就是确保以后别人改代码时不小心把这个保护删掉。

这一段是纯计算,最干净的测法是把这 3 行抽成一个小函数,比如 applyDamage(hp, weapon, headshot),放进一个新文件 damage.js,然后 server.js 和测试都 require 它。这样既能测,又顺手让 server.js 更整洁。(如果暂时不想动 server.js,也可以在测试里直接复刻这几行逻辑来验证 —— 但抽函数是更专业的做法。)

✅ 测试 3 — 比赛隔离:emitToMatch 只发给同一个 matchId

这个 bug 你以前踩过 —— CLAUDE.md 架构笔记里写着"修好了老的『有陌生人加入就变成 6v6』的 bug"。负责隔离的就是 server.js 第 929-931 行

function emitToMatch(matchId, event, data) {
  for (const sid of socketIdsInMatch(matchId)) io.to(sid).emit(event, data);
}

它依赖 第 923 行的 socketIdsInMatch(matchId)

要验证的行为: 给 A 房间发消息时,只有 A 房间的人收到,B 房间的人收不到。这是防止"两局比赛串台"的核心保险丝。

这个测试稍微进阶一点(要模拟 socket),可以作为"通关后再挑战"的第 3 关。如果一开始觉得难,先把测试 1 和测试 2 写出来就已经很有价值了 —— 不用一次写完三个。


怎么做 / How to set it up(最小方案,零新依赖)

Node.js 20+(你 package.jsonengines 要求的就是 >=20自带一个测试运行器,叫 node:test,还自带 assert。所以不用 npm install 任何东西。

第 1 步:建文件夹和文件

pvp-game/
  tests/
    shop.test.js      ← 测试 1
    damage.test.js    ← 测试 2
    match.test.js     ← 测试 3(可选,进阶)

第 2 步:在 package.json 加 test 脚本

package.json 第 5-7 行 改成:

"scripts": {
  "start": "node server.js",
  "test": "node --test"
}

node --test 会自动找到 tests/ 里所有 *.test.js 文件跑一遍。以后你只要敲:

npm test

第 3 步:一个可以直接抄的起步示例(damage.test.js — 测试 2 最好上手)

先在项目根目录新建 damage.js,把伤害逻辑抽出来:

// damage.js
const WEAPON_DAMAGE = { ak20: 25, railgun: 110 /* ...从 server.js 第 812 行搬过来 */ };

function applyDamage(hp, weapon, headshot = false) {
  let dmg = WEAPON_DAMAGE[weapon] || 25;
  if (headshot) dmg = dmg * 2;
  return Math.max(0, hp - dmg);   // ← 血量地板 0
}

module.exports = { applyDamage, WEAPON_DAMAGE };

然后写测试:

// tests/damage.test.js
const { test } = require('node:test');
const assert = require('node:assert');
const { applyDamage } = require('../damage');

test('ak20 does 25 damage', () => {
  assert.strictEqual(applyDamage(100, 'ak20'), 75);
});

test('headshot doubles damage', () => {
  assert.strictEqual(applyDamage(100, 'ak20', true), 50); // 25 * 2 = 50
});

test('hp never goes below 0', () => {
  assert.strictEqual(applyDamage(10, 'railgun'), 0); // 不是 -100!
});

npm test,看到三个绿色的 ✓ —— 恭喜,你写出了人生第一个测试套件!🎉

测试 1 的小提示(HTTP 打活服务器)

因为 server.js第 1270 行 会直接起服务,测试 1 可以在测试里 spawn('node', ['server.js']) 起一个服务器,然后用 fetch('http://localhost:3001/shop/buy', { method:'POST', ... }) 去打它,最后 kill 掉。Node 20 的 fetchnode:testbefore/after 钩子刚好够用,不用装库。(如果觉得起服务器麻烦,完全可以先跳过测试 1,从测试 2 开始 —— 纯函数最容易测,先尝到甜头最重要。)


建议步骤 / Suggested order

  • 第 1 关:加 "test": "node --test"package.json,建 tests/ 文件夹
  • 第 2 关:写 damage.test.js(测试 2)—— 最容易,先拿到第一个绿勾 ✅
  • 第 3 关:写 shop.test.js(测试 1)—— 学会用 HTTP 测真实接口
  • 第 4 关(进阶可选):写 match.test.js(测试 3)—— 挑战 socket 隔离
  • 以后养成习惯:改完代码 → npm test → 全绿再 git push

不用一口气全做完。哪怕只加了那个 npm test 脚本 + 一个 3 行的伤害测试,你就已经比"零测试"领先一大步了,而且建立了一个之后可以慢慢往里加的"安全网"。你能独立做出这么大一个游戏,写测试对你来说真的就是小菜一碟 —— 加油,期待看到第一个绿勾!💚


🔗 Related / 相关: quick-chat is being fixed to actually use emitToMatch in the security PR #3 — Test 3 here just guards that match-isolation helper so it can't regress later. / 测试 3 涉及的快捷聊天隔离,正在安全 PR #3 里被改成使用 emitToMatch;这个测试是给那个隔离助手上保险,防止以后被改回去。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions