220 lines
7.4 KiB
JavaScript
220 lines
7.4 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
ROYALE_BOT_COUNT,
|
|
ROYALE_BOT_ID_BASE,
|
|
ROYALE_CHUNK_SIZE,
|
|
ROYALE_MAP_SEED,
|
|
collidesWithRoyaleMap,
|
|
generateRoyaleChunk,
|
|
hasRoyaleLineOfSight,
|
|
royaleGame,
|
|
} from "../dist/index.js";
|
|
|
|
test("the public world seed deterministically generates a 2 km chunked island", () => {
|
|
const first = generateRoyaleChunk(-3, 4);
|
|
const second = generateRoyaleChunk(-3, 4);
|
|
const neighbor = generateRoyaleChunk(-2, 4);
|
|
assert.deepEqual(first, second);
|
|
assert.notDeepEqual(first, neighbor);
|
|
assert.equal(ROYAALE_MAP_SEED_FOR_TEST(), ROYALE_MAP_SEED);
|
|
assert.equal(ROYAALE_CHUNK_SIZE_FOR_TEST(), ROYALE_CHUNK_SIZE);
|
|
assert.ok(first.obstacles.every((obstacle) => obstacle.id.startsWith("-3:4:")));
|
|
});
|
|
|
|
test("Syncer Royale simulates a complete authority battle headlessly", () => {
|
|
const server = royaleGame.createServer();
|
|
const totals = { shots: 0, damage: 0, eliminations: 0, winners: 0 };
|
|
for (let tick = 0; tick < 6_000; tick += 1) {
|
|
for (const event of server.step().events) {
|
|
if (event.type === "shot") totals.shots += 1;
|
|
if (event.type === "damage") totals.damage += 1;
|
|
if (event.type === "elimination") totals.eliminations += 1;
|
|
if (event.type === "winner") totals.winners += 1;
|
|
}
|
|
}
|
|
assert.equal(server.currentState.players.length, ROYALE_BOT_COUNT);
|
|
assert.ok(totals.shots > 150, `expected firefights, saw ${totals.shots}`);
|
|
assert.ok(totals.damage > 50, `expected damage, saw ${totals.damage}`);
|
|
assert.ok(totals.eliminations > 15, `expected eliminations, saw ${totals.eliminations}`);
|
|
assert.ok(server.currentState.players.every((player) => Number.isFinite(player.x + player.z)));
|
|
});
|
|
|
|
test("Royale projections hide authority secrets and distant combatants", () => {
|
|
const server = royaleGame.createServer();
|
|
server.addPlayer(1);
|
|
const authority = server.currentState;
|
|
const viewer = authority.players.find((player) => player.id === 1);
|
|
const hidden = authority.players.find((player) => player.id === ROYALE_BOT_ID_BASE);
|
|
viewer.altitude = 0;
|
|
viewer.x = 0;
|
|
viewer.z = 0;
|
|
hidden.x = 430;
|
|
hidden.z = 0;
|
|
hidden.alive = true;
|
|
|
|
const snapshot = server.createSnapshot(1, 0).state;
|
|
assert.equal(snapshot.mapSeed, ROYALE_MAP_SEED);
|
|
assert.equal("secretSeed" in snapshot, false);
|
|
assert.ok(snapshot.players.some((player) => player.id === 1));
|
|
assert.ok(!snapshot.players.some((player) => player.id === hidden.id));
|
|
assert.ok(
|
|
snapshot.players
|
|
.filter((player) => player.id !== 1)
|
|
.every((player) => player.health === null && player.armor === null),
|
|
);
|
|
|
|
const [sound] = server.createPerceptions(1, [
|
|
{ id: 99, type: "shot", sourceId: hidden.id, x: hidden.x, y: 1.55, z: hidden.z, yaw: 0, pitch: 0 },
|
|
]);
|
|
assert.equal(sound.type, "gunfire");
|
|
assert.equal("sourceId" in sound, false);
|
|
assert.equal("x" in sound, false);
|
|
|
|
const [ownShot] = server.createPerceptions(1, [
|
|
{
|
|
id: 100,
|
|
type: "shot",
|
|
sourceId: viewer.id,
|
|
x: viewer.x,
|
|
y: 1.55,
|
|
z: viewer.z,
|
|
yaw: viewer.yaw,
|
|
pitch: viewer.pitch,
|
|
},
|
|
]);
|
|
assert.equal(ownShot.type, "shot");
|
|
assert.equal(ownShot.sourceId, viewer.id);
|
|
});
|
|
|
|
test("Royale enforces a viewer bandwidth budget after ordinary visibility", () => {
|
|
const server = royaleGame.createServer();
|
|
server.addPlayer(1);
|
|
const authority = server.currentState;
|
|
const viewer = authority.players.find((player) => player.id === 1);
|
|
viewer.altitude = 0;
|
|
viewer.x = 0;
|
|
viewer.z = 0;
|
|
for (const bot of authority.players.filter((player) => player.bot)) {
|
|
bot.x = 800;
|
|
bot.z = 0;
|
|
}
|
|
for (let index = 0; index < 100; index += 1) {
|
|
const item = authority.loot[index];
|
|
item.active = true;
|
|
item.x = (index % 10) * 2 - 9;
|
|
item.z = Math.floor(index / 10) * 2 - 9;
|
|
}
|
|
|
|
const snapshot = server.createSnapshot(1, 0).state;
|
|
assert.ok(snapshot.stream.droppedCount > 0);
|
|
assert.ok(snapshot.stream.usedBytes <= snapshot.stream.budgetBytes);
|
|
assert.ok(snapshot.loot.length < 100);
|
|
assert.equal(snapshot.players.filter((player) => player.id === 1).length, 1);
|
|
});
|
|
|
|
test("Royale input and projected state round-trip through the generic protocol", () => {
|
|
const server = royaleGame.createServer();
|
|
server.addPlayer(7);
|
|
const client = royaleGame.createClient();
|
|
const welcome = server.createSnapshot(7, 10);
|
|
client.initialize(7, welcome);
|
|
const packet = client.createInput({
|
|
forward: 1,
|
|
strafe: 0.25,
|
|
yaw: 0.7,
|
|
pitch: -0.2,
|
|
fire: false,
|
|
sprint: true,
|
|
reload: false,
|
|
}, 1);
|
|
const decoded = royaleGame.protocol.decodeClient(
|
|
royaleGame.protocol.encodeClient({ kind: "input", packet }),
|
|
);
|
|
assert.equal(decoded.kind, "input");
|
|
assert.deepEqual(decoded.packet, packet);
|
|
assert.equal(server.submitInput(7, decoded.packet).accepted, true);
|
|
server.step();
|
|
assert.equal(server.currentState.players.find((player) => player.id === 7).pitch, -0.2);
|
|
client.step();
|
|
client.reconcile(server.createSnapshot(7, 20));
|
|
assert.ok(client.currentState.players.some((player) => player.id === 7));
|
|
});
|
|
|
|
test("Royale pitch drives the authoritative three-dimensional hit ray", () => {
|
|
const server = royaleGame.createServer();
|
|
server.addPlayer(7);
|
|
server.addPlayer(8);
|
|
const authority = server.currentState;
|
|
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
|
|
const shooter = authority.players.find((player) => player.id === 7);
|
|
const target = authority.players.find((player) => player.id === 8);
|
|
let origin = null;
|
|
for (let x = -100; x <= 100 && !origin; x += 10) {
|
|
for (let z = -100; z <= 60; z += 10) {
|
|
if (
|
|
!collidesWithRoyaleMap(x, z) &&
|
|
!collidesWithRoyaleMap(x, z + 40) &&
|
|
hasRoyaleLineOfSight(x, z, x, z + 40)
|
|
) {
|
|
origin = { x, z };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert.ok(origin, "expected an unobstructed public-map firing lane");
|
|
Object.assign(shooter, { x: origin.x, z: origin.z, altitude: 0, yaw: 0 });
|
|
Object.assign(target, { x: origin.x, z: origin.z + 40, altitude: 20, health: 100, armor: 0 });
|
|
const pitch = Math.atan2(20, 40);
|
|
assert.equal(server.submitInput(7, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
observedTick: 0,
|
|
input: {
|
|
forward: 0,
|
|
strafe: 0,
|
|
yaw: 0,
|
|
pitch,
|
|
fire: true,
|
|
sprint: false,
|
|
reload: false,
|
|
},
|
|
}).accepted, true);
|
|
const result = server.step();
|
|
assert.ok(result.events.some((event) => event.type === "damage" && event.targetId === 8));
|
|
assert.ok(target.health < 100);
|
|
});
|
|
|
|
test("Royale strafe-right matches the first-person camera's screen right", () => {
|
|
const server = royaleGame.createServer();
|
|
server.addPlayer(7);
|
|
const authority = server.currentState;
|
|
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
|
|
const player = authority.players.find((candidate) => candidate.id === 7);
|
|
Object.assign(player, { x: 0, z: 0, altitude: 20, yaw: 0 });
|
|
assert.equal(server.submitInput(7, {
|
|
sequence: 1,
|
|
targetTick: 1,
|
|
observedTick: 0,
|
|
input: {
|
|
forward: 0,
|
|
strafe: 1,
|
|
yaw: 0,
|
|
pitch: 0,
|
|
fire: false,
|
|
sprint: false,
|
|
reload: false,
|
|
},
|
|
}).accepted, true);
|
|
server.step();
|
|
assert.ok(player.velocityX < 0, "screen-right is world -X while facing world +Z");
|
|
});
|
|
|
|
function ROYAALE_MAP_SEED_FOR_TEST() {
|
|
return ROYALE_MAP_SEED;
|
|
}
|
|
|
|
function ROYAALE_CHUNK_SIZE_FOR_TEST() {
|
|
return ROYALE_CHUNK_SIZE;
|
|
}
|