321 lines
9.3 KiB
JavaScript
321 lines
9.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
BOT_COUNT,
|
|
BOT_ID_BASE,
|
|
Weapon,
|
|
createShooterGame,
|
|
shooterGame,
|
|
} from "../dist/index.js";
|
|
|
|
function runHeadlessMatch(ticks) {
|
|
const server = shooterGame.createServer();
|
|
const totals = {
|
|
shots: 0,
|
|
damageEvents: 0,
|
|
eliminations: 0,
|
|
respawns: 0,
|
|
};
|
|
|
|
for (let tick = 0; tick < ticks; tick += 1) {
|
|
const result = server.step();
|
|
for (const event of result.events) {
|
|
if (event.type === "shot") totals.shots += 1;
|
|
if (event.type === "damage") totals.damageEvents += 1;
|
|
if (event.type === "elimination") totals.eliminations += 1;
|
|
if (event.type === "respawn") totals.respawns += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...totals,
|
|
tick: server.tick,
|
|
scores: [...server.currentState.players.values()]
|
|
.map((player) => ({
|
|
id: player.id,
|
|
kills: player.kills,
|
|
deaths: player.deaths,
|
|
alive: player.alive,
|
|
x: Number(player.x.toFixed(5)),
|
|
z: Number(player.z.toFixed(5)),
|
|
}))
|
|
.sort((a, b) => a.id - b.id),
|
|
};
|
|
}
|
|
|
|
test("the complete shooter match simulates headlessly on the backend", () => {
|
|
const match = runHeadlessMatch(7_200);
|
|
assert.equal(match.tick, 7_200);
|
|
assert.equal(match.scores.length, BOT_COUNT);
|
|
assert.ok(match.shots > 700, `expected active combat, saw ${match.shots} shots`);
|
|
assert.ok(match.damageEvents > 50, `expected hits, saw ${match.damageEvents}`);
|
|
assert.ok(match.eliminations >= 10, `expected kills, saw ${match.eliminations}`);
|
|
assert.ok(match.respawns >= 8, `expected respawns, saw ${match.respawns}`);
|
|
assert.equal(
|
|
match.scores.reduce((sum, score) => sum + score.kills, 0),
|
|
match.eliminations,
|
|
);
|
|
assert.ok(match.scores.every((score) => Number.isFinite(score.x + score.z)));
|
|
});
|
|
|
|
test("backend simulation is deterministic", () => {
|
|
assert.deepEqual(runHeadlessMatch(2_400), runHeadlessMatch(2_400));
|
|
});
|
|
|
|
test("held automatic fire follows server ticks while semi-auto remains edge-triggered", () => {
|
|
const game = createShooterGame({ botCount: 0 });
|
|
|
|
const runHeldFire = (weapon, ticks) => {
|
|
const server = game.createServer();
|
|
server.addPlayer(1);
|
|
server.currentState.players.get(1).weapon = weapon;
|
|
const packet = {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
observedTick: 0,
|
|
input: {
|
|
strafe: 0,
|
|
forward: 0,
|
|
yaw: 0,
|
|
pitch: 0,
|
|
fire: true,
|
|
sprint: false,
|
|
reload: false,
|
|
weapon,
|
|
},
|
|
};
|
|
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
|
|
let shots = 0;
|
|
for (let tick = 0; tick < ticks; tick += 1) {
|
|
if (tick > 0 && tick % 10 === 0) {
|
|
assert.deepEqual(server.submitInput(1, packet), { accepted: true });
|
|
}
|
|
shots += server.step().events.filter((event) => event.type === "shot").length;
|
|
}
|
|
return shots;
|
|
};
|
|
|
|
assert.equal(runHeldFire(Weapon.PulseRifle, 60), 10);
|
|
assert.equal(runHeldFire(Weapon.Scattergun, 100), 1);
|
|
});
|
|
|
|
test("human hitscan damages the historical target without rewinding live positions", () => {
|
|
const server = shooterGame.createServer();
|
|
server.addPlayer(1);
|
|
const shooter = server.currentState.players.get(1);
|
|
const target = server.currentState.players.get(BOT_ID_BASE);
|
|
|
|
for (const bot of server.currentState.players.values()) {
|
|
if (bot.id === BOT_ID_BASE || bot.id === 1) continue;
|
|
bot.alive = false;
|
|
bot.respawnTicks = 10_000;
|
|
}
|
|
shooter.x = -4;
|
|
shooter.z = -15;
|
|
shooter.yaw = Math.PI / 2;
|
|
shooter.pitch = 0;
|
|
shooter.spawnProtectionTicks = 0;
|
|
target.x = 0;
|
|
target.z = -15;
|
|
target.velocityX = 0;
|
|
target.velocityZ = 0;
|
|
target.inputForward = 0;
|
|
target.inputStrafe = 0;
|
|
target.spawnProtectionTicks = 0;
|
|
target.bot = null;
|
|
|
|
server.step();
|
|
target.z = -12.5;
|
|
const healthBefore = target.health;
|
|
server.submitInput(1, {
|
|
sequence: 1,
|
|
targetTick: 2,
|
|
observedTick: 1,
|
|
input: {
|
|
strafe: 0,
|
|
forward: 0,
|
|
yaw: Math.PI / 2,
|
|
pitch: 0,
|
|
fire: true,
|
|
sprint: false,
|
|
reload: false,
|
|
weapon: Weapon.PulseRifle,
|
|
},
|
|
});
|
|
const result = server.step();
|
|
|
|
assert.equal(target.z, -12.5, "the live target must not move backward");
|
|
assert.ok(target.health < healthBefore, "the rewound hitbox should take damage");
|
|
assert.ok(
|
|
result.events.some(
|
|
(event) => event.type === "damage" && event.targetId === BOT_ID_BASE,
|
|
),
|
|
);
|
|
const shot = result.events.find(
|
|
(event) => event.type === "shot" && event.sourceId === 1,
|
|
);
|
|
assert.ok(shot);
|
|
assert.ok(Math.abs(shot.endZ + 15) < 0.01);
|
|
});
|
|
|
|
test("shooter recordings seek through checkpoints with privacy intact", () => {
|
|
const server = shooterGame.createServer({ replaySeed: 99 });
|
|
server.addPlayer(1);
|
|
for (let tick = 0; tick < 420; tick += 1) server.step();
|
|
|
|
const recording = server.exportRecording();
|
|
assert.equal(recording.durationTicks, 420);
|
|
assert.deepEqual(
|
|
recording.checkpoints.map(({ tick }) => tick),
|
|
[0, 120, 240, 360],
|
|
);
|
|
|
|
const replay = shooterGame.createReplay(recording);
|
|
replay.seek(135);
|
|
const finalFrame = replay.seek(420);
|
|
assert.deepEqual(finalFrame.state, server.currentState);
|
|
|
|
const playerView = replay.viewAs(1).state;
|
|
assert.ok(playerView.players.has(1));
|
|
assert.equal("nextEventId" in playerView, false);
|
|
assert.ok(
|
|
[...playerView.players.values()].every((player) => !("bot" in player)),
|
|
);
|
|
});
|
|
|
|
test("a human elimination creates a bounded attacker-perspective killcam", () => {
|
|
const server = shooterGame.createServer();
|
|
server.addPlayer(1);
|
|
let ticket = null;
|
|
for (let tick = 0; tick < 600 && !ticket; tick += 1) {
|
|
server.step();
|
|
ticket = server.drainReplayTickets(1)[0] ?? null;
|
|
}
|
|
|
|
assert.ok(ticket, "expected the standing human to receive a bot killcam");
|
|
assert.equal(ticket.requesterId, 1);
|
|
assert.ok(ticket.perspectiveId >= BOT_ID_BASE);
|
|
assert.equal(ticket.toTick, ticket.issuedAtTick);
|
|
assert.ok(ticket.frames.length > 60);
|
|
assert.ok(ticket.frames.length < 300);
|
|
assert.ok(
|
|
ticket.frames.every(({ state }) =>
|
|
[...state.players.values()].every(
|
|
(player) => !("bot" in player) && !("kills" in player),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("snapshots and perception events cross the binary protocol", () => {
|
|
const server = shooterGame.createServer();
|
|
server.addPlayer(1);
|
|
const snapshot = server.createSnapshot(1, 123.5);
|
|
const snapshotFrame = shooterGame.protocol.encodeServer({
|
|
kind: "snapshot",
|
|
snapshot,
|
|
});
|
|
const decodedSnapshot = shooterGame.protocol.decodeServer(snapshotFrame);
|
|
assert.equal(decodedSnapshot.kind, "snapshot");
|
|
assert.equal(decodedSnapshot.snapshot.state.players.get(1).ammo.length, 3);
|
|
|
|
const event = {
|
|
id: 99,
|
|
type: "shot",
|
|
weapon: Weapon.RailRifle,
|
|
sourceId: 1,
|
|
originX: 1,
|
|
originY: 1.58,
|
|
originZ: 2,
|
|
endX: 11,
|
|
endY: 1.4,
|
|
endZ: -4,
|
|
impact: "wall",
|
|
};
|
|
const eventFrame = shooterGame.protocol.encodeServer({
|
|
kind: "event",
|
|
tick: 50,
|
|
event,
|
|
});
|
|
const decodedEvent = shooterGame.protocol.decodeServer(eventFrame);
|
|
assert.equal(decodedEvent.kind, "event");
|
|
assert.equal(decodedEvent.tick, 50);
|
|
assert.deepEqual(
|
|
{
|
|
...decodedEvent.event,
|
|
originY: Number(decodedEvent.event.originY.toFixed(2)),
|
|
endY: Number(decodedEvent.event.endY.toFixed(2)),
|
|
},
|
|
event,
|
|
);
|
|
});
|
|
|
|
test("occluded combatants stay private while their gunfire becomes anonymous audio", () => {
|
|
const server = shooterGame.createServer();
|
|
server.addPlayer(1);
|
|
const authority = server.currentState;
|
|
const viewer = authority.players.get(1);
|
|
const hiddenBot = authority.players.get(BOT_ID_BASE);
|
|
viewer.x = 0;
|
|
viewer.z = -6;
|
|
viewer.yaw = 0;
|
|
hiddenBot.x = 0;
|
|
hiddenBot.z = 6;
|
|
|
|
const snapshot = server.createSnapshot(1, 0);
|
|
assert.equal(snapshot.state.players.has(BOT_ID_BASE), false);
|
|
|
|
const perceptions = server.createPerceptions(1, [
|
|
{
|
|
id: 777,
|
|
type: "shot",
|
|
sourceId: BOT_ID_BASE,
|
|
weapon: Weapon.PulseRifle,
|
|
originX: hiddenBot.x,
|
|
originY: 1.58,
|
|
originZ: hiddenBot.z,
|
|
endX: hiddenBot.x,
|
|
endY: 1.58,
|
|
endZ: hiddenBot.z - 10,
|
|
impact: "wall",
|
|
},
|
|
]);
|
|
assert.equal(perceptions.length, 1);
|
|
assert.equal(perceptions[0].type, "sound");
|
|
assert.equal("sourceId" in perceptions[0], false);
|
|
assert.equal("originX" in perceptions[0], false);
|
|
});
|
|
|
|
test("visible opponents expose exact health but redact armor and inventory", () => {
|
|
const server = shooterGame.createServer();
|
|
server.addPlayer(1);
|
|
const viewer = server.currentState.players.get(1);
|
|
const opponent = server.currentState.players.get(BOT_ID_BASE);
|
|
viewer.x = -4;
|
|
viewer.z = -15;
|
|
viewer.yaw = Math.PI / 2;
|
|
opponent.x = 0;
|
|
opponent.z = -15;
|
|
opponent.health = 37;
|
|
opponent.armor = 19;
|
|
opponent.ammo[Weapon.PulseRifle].magazine = 3;
|
|
|
|
const replicated = server.createSnapshot(1, 0).state.players.get(BOT_ID_BASE);
|
|
assert.ok(replicated, "opponent should be visible in the clear corridor");
|
|
assert.equal(replicated.health, 37);
|
|
assert.equal(replicated.armor, 0);
|
|
assert.ok(
|
|
replicated.ammo.every(
|
|
(ammo) => ammo.magazine === 0 && ammo.reserve === 0,
|
|
),
|
|
);
|
|
assert.equal(replicated.x, opponent.x);
|
|
assert.equal(replicated.weapon, opponent.weapon);
|
|
|
|
viewer.yaw = -Math.PI / 2;
|
|
assert.equal(
|
|
server.createSnapshot(1, 0).state.players.has(BOT_ID_BASE),
|
|
false,
|
|
);
|
|
});
|