548 lines
36 KiB
JavaScript
548 lines
36 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const vm = require("vm");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
|
|
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
|
const context = { console, Math, Date, JSON, isFinite };
|
|
vm.createContext(context);
|
|
vm.runInContext(source, context);
|
|
|
|
assert.strictEqual(context.ENEMIES.length, 9);
|
|
assert.strictEqual(context.SNAPSHOT_TICKS, 1, "Online snapshots should be sent at the full 10 Hz simulation rate.");
|
|
assert.strictEqual(context.ENEMIES[0].category, "air");
|
|
assert.strictEqual(context.ENEMIES[6].category, "ground");
|
|
assert.strictEqual(context.TOWERS.length, 7);
|
|
assert.strictEqual(context.TOWERS[0].targetClass, "ground");
|
|
assert.strictEqual(context.TOWERS[1].targetClass, "both");
|
|
assert.ok(context.TOWERS[1].bothScale < 1, "The all-purpose laser should trade damage for shield-breaking ground coverage.");
|
|
assert.strictEqual(context.TOWERS[3].targetClass, "both");
|
|
assert.ok(context.TOWERS[3].bothScale < 1, "Universal towers should trade damage for flexibility.");
|
|
assert.strictEqual(context.TOWERS[4].damage, 20, "EMP damage should be tuned down to about 20.");
|
|
assert.strictEqual(context.TOWERS[4].targetClass, "both", "EMP should cover both air and ground units.");
|
|
assert.strictEqual(context.TOWERS[6].targetClass, "air", "The Flak tower must only target air units.");
|
|
assert.strictEqual(context.TOWERS[6].damage, 22, "The air-only Flak tower should receive its focused damage tuning.");
|
|
assert.strictEqual(context.ENEMIES[5].hp, 620, "The first aerial boss base hull should use the reduced balance value.");
|
|
assert.strictEqual(context.initialState({ difficulty: 0 }).difficulty, 0, "Easy must not be coerced to the default difficulty.");
|
|
assert.strictEqual(context.initialState({ difficulty: 0 }).credits[0], context.DIFFICULTIES[0].startCredits);
|
|
const balanceStorage = new Map();
|
|
let balanceVersion = 0;
|
|
const balanceNk = {
|
|
storageRead(ops) { return ops.map(op => balanceStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
|
storageWrite(ops) {
|
|
for (const op of ops) {
|
|
balanceVersion += 1;
|
|
balanceStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: String(balanceVersion) });
|
|
}
|
|
}
|
|
};
|
|
const serverBalance = JSON.parse(context.rpcGetGameBalance({}, {}, balanceNk, "{}"));
|
|
assert.strictEqual(serverBalance.config.towers[0].damage, 22, "The internal PHP page calls balance read through Nakama's HTTP-key server-to-server RPC context, which has no userId.");
|
|
const adminBalance = JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}"));
|
|
assert.strictEqual(adminBalance.config.towers[0].damage, 22);
|
|
const editedBalance = JSON.parse(JSON.stringify(adminBalance.config));
|
|
editedBalance.towers[0].damage = 31;
|
|
editedBalance.enemies[0].hp = 90;
|
|
editedBalance.admin_tower.range = 700;
|
|
editedBalance.admin_tower.cooldown = 0.12;
|
|
assert.throws(() => context.rpcSetGameBalance({ userId: "pilot-b" }, {}, balanceNk, JSON.stringify({ config: editedBalance })), /server-to-server/);
|
|
assert.strictEqual(JSON.parse(context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: editedBalance }))).accepted, true);
|
|
assert.strictEqual(JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}")).config.towers[0].damage, 31);
|
|
assert.strictEqual(context.matchInit({}, {}, balanceNk, { difficulty: 1 }).state.balance.enemies[0].hp, 90);
|
|
assert.strictEqual(context.matchInit({}, {}, balanceNk, { difficulty: 1 }).state.balance.admin_tower.range, 700);
|
|
assert.throws(() => context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: { towers: [], enemies: [] } })), /invalid balance schema/);
|
|
const legacyStorage = new Map();
|
|
const legacyKey = `game_balance:${context.SYSTEM_USER_ID}:global`;
|
|
const legacyConfig = JSON.parse(JSON.stringify(context.defaultBalanceConfig()));
|
|
legacyConfig.schema = 1;
|
|
legacyConfig.towers[0].damage = 31;
|
|
legacyConfig.towers[6].damage = 20;
|
|
legacyConfig.enemies[5].hp = 740;
|
|
legacyStorage.set(legacyKey, { collection: "game_balance", key: "global", userId: context.SYSTEM_USER_ID,
|
|
value: { config: legacyConfig, updated_at: 1 }, permissionRead: 0, permissionWrite: 0, version: "7" });
|
|
const migrationNk = {
|
|
storageRead(ops) { return ops.map(op => legacyStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
|
storageWrite(ops) { for (const op of ops) legacyStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: "8" }); }
|
|
};
|
|
const migratedBalance = context.readBalanceConfig(migrationNk);
|
|
assert.strictEqual(migratedBalance.config.schema, 3);
|
|
assert.strictEqual(migratedBalance.config.towers[0].damage, 31, "Balance migrations must preserve unrelated admin tuning.");
|
|
assert.strictEqual(migratedBalance.config.towers[6].damage, 22, "Unchanged legacy Flak defaults should migrate to the new value.");
|
|
assert.strictEqual(migratedBalance.config.enemies[5].hp, 620, "Unchanged legacy boss HP should migrate to the new value.");
|
|
assert.strictEqual(migratedBalance.config.admin_tower.range, 650, "Older balance settings should receive the safer admin range.");
|
|
assert.strictEqual(legacyStorage.get(legacyKey).value.config.schema, 3, "Migrated balance should persist for future matches.");
|
|
const hostId = "11111111-1111-1111-1111-111111111111";
|
|
const friendId = "22222222-2222-2222-2222-222222222222";
|
|
const sentInvites = [];
|
|
let inviteLimitRecord = null;
|
|
const inviteNk = {
|
|
storageRead(requests) {
|
|
return requests[0].collection === "private_rooms"
|
|
? [{ value: { host_user_id: hostId, match_id: "private-match" } }]
|
|
: requests[0].collection === "friend_invite_limits" && inviteLimitRecord ? [inviteLimitRecord] : [];
|
|
},
|
|
storageWrite(items) {
|
|
if (items[0].collection === "friend_invite_limits") inviteLimitRecord = { value: items[0].value };
|
|
},
|
|
usersGetId() { return [{ displayName: "Commander" }]; },
|
|
friendsList() { return { friends: [{ state: 0, user: { userId: friendId } }], cursor: "" }; },
|
|
notificationsSend(items) { sentInvites.push(...items); }
|
|
};
|
|
const inviteCtx = { userId: hostId, username: "pilot_host" };
|
|
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, true);
|
|
assert.strictEqual(sentInvites.length, 1);
|
|
assert.strictEqual(sentInvites[0].content.room_code, "ABC123");
|
|
assert.strictEqual(sentInvites[0].content.from, "Commander");
|
|
assert.strictEqual(sentInvites[0].persistent, false, "Game invitations must not remain as stale offline messages.");
|
|
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).error, "invite_cooldown", "Repeated invitations must be rate limited.");
|
|
assert.strictEqual(sentInvites.length, 1, "A rate-limited invitation must not send another notification.");
|
|
assert.strictEqual(JSON.parse(context.rpcInviteFriend({ userId: friendId }, {}, inviteNk, JSON.stringify({ friend_id: hostId, room_code: "ABC123" }))).accepted, false, "Only the private room host may invite.");
|
|
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, { ...inviteNk, friendsList: () => ({ friends: [] }) }, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, false, "Only mutual friends may be invited.");
|
|
const nameLookupNk = {
|
|
sqlQuery(_query, params) {
|
|
assert.strictEqual(params[0], "Emporex2", "Player search must use parameter binding.");
|
|
return [{ id: friendId, display_name: "Emporex2" }];
|
|
}
|
|
};
|
|
assert.deepStrictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {}, nameLookupNk, JSON.stringify({ display_name: "Emporex2" }))),
|
|
{ found: true, user_id: friendId, display_name: "Emporex2" });
|
|
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
|
|
{ sqlQuery: () => [{ id: friendId, display_name: "Same" }, { id: "third", display_name: "Same" }] },
|
|
JSON.stringify({ display_name: "Same" }))).error, "ambiguous", "Duplicate display names must not invite the wrong account.");
|
|
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
|
|
{ sqlQuery: () => [{ id: hostId, display_name: "Self" }] },
|
|
JSON.stringify({ display_name: "Self" }))).error, "not_found");
|
|
const flakState = {
|
|
towers: [{ id: 20, type: 6, x: 800, y: 200, owner: 0, level: 1, cooldown: 0 }],
|
|
enemies: [
|
|
{ id: 401, type: 0, x: 850, y: 200, route_id: 0, path_index: 1, hp: 100, shield: false },
|
|
{ id: 402, type: 6, x: 850, y: 210, route_id: 0, path_index: 1, hp: 100, shield: false }
|
|
],
|
|
credits: [0, 0]
|
|
};
|
|
context.updateTowers(flakState, 1);
|
|
assert.ok(flakState.enemies.find((enemy) => enemy.id === 401).hp < 100, "Flak should damage air units.");
|
|
assert.strictEqual(flakState.enemies.find((enemy) => enemy.id === 402).hp, 100, "Flak must ignore ground units.");
|
|
assert.strictEqual(flakState.shots.length, 1, "Firing towers should publish a visual shot event.");
|
|
assert.strictEqual(flakState.shots[0].tower_id, 20);
|
|
assert.strictEqual(flakState.shots[0].type, 6);
|
|
const shieldTest = { hp: 100, shield: true, shield_hp: 30, max_shield_hp: 30, shield_resistance: 0.28 };
|
|
context.applyDamageToEnemy(shieldTest, 20, 1);
|
|
assert.strictEqual(shieldTest.hp, 100, "Laser damage should drain shield before hull.");
|
|
assert.strictEqual(shieldTest.shield_hp, 10);
|
|
context.applyDamageToEnemy(shieldTest, 20, 1);
|
|
assert.strictEqual(shieldTest.shield, false);
|
|
assert.strictEqual(shieldTest.hp, 90, "Laser damage beyond shield capacity should spill into hull.");
|
|
const priorityState = {
|
|
towers: [{ id: 1, type: 1, x: 800, y: 120, owner: 0, level: 1, cooldown: 0 }],
|
|
enemies: [
|
|
{ id: 201, type: 6, x: 850, y: 120, path_index: 1, hp: 100, shield: true, shield_hp: 50, max_shield_hp: 50, shield_resistance: 0.28 },
|
|
{ id: 202, type: 0, x: 950, y: 120, path_index: 1, hp: 100, shield: false }
|
|
],
|
|
credits: [0, 0], wave: 1
|
|
};
|
|
context.updateTowers(priorityState, 1);
|
|
assert.ok(priorityState.enemies[0].shield_hp < 50, "Online lasers should drain a shield before firing at unshielded units.");
|
|
assert.strictEqual(priorityState.enemies[1].hp, 100);
|
|
|
|
const railgunState = {
|
|
towers: [{ id: 10, type: 5, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
|
|
enemies: [
|
|
{ id: 301, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
|
|
{ id: 302, type: 6, x: 500, y: 120, path_index: 1, hp: 1000, shield: false },
|
|
{ id: 303, type: 0, x: 450, y: 150, path_index: 1, hp: 1000, shield: false }
|
|
],
|
|
credits: [0, 0], wave: 1
|
|
};
|
|
context.updateTowers(railgunState, 0.01);
|
|
assert.ok(railgunState.enemies[0].hp < 1000 && railgunState.enemies[1].hp < 1000, "Online railgun should pierce enemies behind its first hit on the same line.");
|
|
assert.strictEqual(railgunState.enemies[2].hp, 1000, "Online railgun should not hit units away from its beam line.");
|
|
|
|
const empState = {
|
|
towers: [{ id: 11, type: 4, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
|
|
enemies: [
|
|
{ id: 401, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
|
|
{ id: 402, type: 6, x: 450, y: 120, path_index: 1, hp: 1000, shield: false },
|
|
{ id: 403, type: 6, x: 570, y: 120, path_index: 1, hp: 1000, shield: false }
|
|
],
|
|
credits: [0, 0], wave: 1
|
|
};
|
|
context.updateTowers(empState, 0.01);
|
|
assert.strictEqual(empState.enemies[0].hp, 980, "EMP should deal 20 damage to its primary target.");
|
|
assert.strictEqual(empState.enemies[1].hp, 980, "EMP should deal its reduced 20 damage to nearby ground units too.");
|
|
assert.strictEqual(empState.enemies[2].hp, 1000, "EMP should not damage units outside its area radius.");
|
|
|
|
const sent = [];
|
|
const dispatcher = {
|
|
broadcastMessage(opCode, data) {
|
|
sent.push({ opCode, data: JSON.parse(data) });
|
|
},
|
|
matchLabelUpdate(label) {
|
|
let data = label;
|
|
try { data = JSON.parse(label); } catch (error) {}
|
|
sent.push({ opCode: "label", data });
|
|
}
|
|
};
|
|
const nk = { binaryToString: data => Buffer.from(data).toString("utf8") };
|
|
const logger = { info() {} };
|
|
const presenceA = { userId: "pilot-a", sessionId: "a", username: "Pilot A" };
|
|
const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" };
|
|
|
|
let updatedAccount = null;
|
|
let deletedAccount = null;
|
|
const accountNk = {
|
|
storageRead() { return []; },
|
|
accountUpdateId(...args) { updatedAccount = args; },
|
|
accountDeleteId(...args) { deletedAccount = args; }
|
|
};
|
|
assert.deepStrictEqual(JSON.parse(context.rpcUpdateDisplayName({ userId: "pilot-a" }, logger, accountNk, JSON.stringify({ display_name: " Kommandant Ägir " }))), { accepted: true, display_name: "Kommandant Ägir" });
|
|
assert.strictEqual(updatedAccount[0], "pilot-a");
|
|
assert.strictEqual(updatedAccount[2], "Kommandant Ägir");
|
|
assert.strictEqual(JSON.parse(context.rpcUpdateDisplayName({ userId: "pilot-a" }, logger, accountNk, JSON.stringify({ display_name: "ADMIN" }))).accepted, false);
|
|
assert.strictEqual(JSON.parse(context.rpcUpdateDisplayName({ userId: "pilot-a" }, logger, accountNk, JSON.stringify({ display_name: "A!" }))).accepted, false);
|
|
assert.deepStrictEqual(JSON.parse(context.rpcDeleteAccount({ userId: "pilot-a" }, logger, accountNk, "{}")), { accepted: true });
|
|
assert.deepStrictEqual(deletedAccount, ["pilot-a", false]);
|
|
|
|
const metricStorage = new Map();
|
|
let metricVersion = 0;
|
|
const metricNk = {
|
|
storageRead(ops) {
|
|
return ops.map(op => metricStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean);
|
|
},
|
|
storageWrite(ops) {
|
|
for (const op of ops) {
|
|
const storageKey = `${op.collection}:${op.userId}:${op.key}`;
|
|
const previous = metricStorage.get(storageKey);
|
|
if (op.version === "*" && previous) throw new Error("version conflict");
|
|
if (op.version && op.version !== "*" && (!previous || previous.version !== op.version)) throw new Error("version conflict");
|
|
metricVersion += 1;
|
|
metricStorage.set(storageKey, { value: JSON.parse(JSON.stringify(op.value)), version: String(metricVersion) });
|
|
}
|
|
}
|
|
};
|
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.2" }))), { accepted: true });
|
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.2" }))), { accepted: true });
|
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordActivity({ userId: "pilot-metric-b" }, logger, metricNk, JSON.stringify({ platform: "Android", version: "0.1.0-alpha.2" }))), { accepted: true });
|
|
const privateStats = JSON.parse(context.rpcPrivateStats({}, logger, metricNk, "{}"));
|
|
assert.strictEqual(privateStats.total_players, 2);
|
|
assert.strictEqual(privateStats.platforms.windows, 1);
|
|
assert.strictEqual(privateStats.platforms.android, 1);
|
|
assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
|
|
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
|
|
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
|
|
const metricSource = "guest_0123456789abcdef0123456789abcdef";
|
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
|
JSON.stringify({ event: "play_start", source: metricSource, sequence: 1, at: Date.now() }))), { accepted: true, duplicate: false });
|
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
|
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: false });
|
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
|
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: true });
|
|
assert.strictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
|
JSON.stringify({ event: "tower_build", tower_type: 7, source: metricSource, sequence: 3 }))).accepted, false, "Admin towers must not enter real tower statistics.");
|
|
context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.16" }));
|
|
const metricActivity = metricStorage.get("player_activity:pilot-metric-a:summary").value;
|
|
assert.strictEqual(metricActivity.tower_builds[1], 1, "Repeated telemetry must not double-count towers.");
|
|
assert.ok(metricActivity.last_played > 0, "Game sessions should track when the player last played.");
|
|
assert.strictEqual(context.isInternalTestUsername("godot_a_2323883"), true);
|
|
assert.strictEqual(context.isInternalTestUsername("social_b_2289087"), true);
|
|
assert.strictEqual(context.isInternalTestUsername("Emporex"), false);
|
|
const directoryNk = {
|
|
sqlQuery(sql) {
|
|
if (sql.includes("count(*) AS total")) return [{ total: 7, active_today: 3 }];
|
|
if (sql.includes("WHERE id=$1::uuid")) return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000 }];
|
|
return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000, last_seen: 2000, last_played: 3000, tower_build_total: 2 }];
|
|
},
|
|
storageRead() { return [{ value: { tower_builds: [0, 2, 0, 0, 0, 0, 0], tower_build_total: 2, last_seen: 2000, last_played: 3000 } }]; }
|
|
};
|
|
assert.strictEqual(JSON.parse(context.rpcListRegisteredPlayers({}, logger, directoryNk, "{}")).total, 7);
|
|
assert.strictEqual(JSON.parse(context.rpcGetRegisteredPlayer({}, logger, directoryNk,
|
|
JSON.stringify({ user_id: "11111111-1111-1111-1111-111111111111" }))).player.tower_builds[1], 2);
|
|
assert.throws(() => context.rpcListRegisteredPlayers({ userId: "pilot-a" }, logger, directoryNk, "{}"), /server-to-server/);
|
|
nk.storageRead = metricNk.storageRead.bind(metricNk);
|
|
nk.storageWrite = metricNk.storageWrite.bind(metricNk);
|
|
|
|
const promoStorage = new Map();
|
|
let promoVersion = 0;
|
|
let promoCodeSequence = 0;
|
|
let beforePromoDelete = null;
|
|
const promoNk = {
|
|
uuidv4() {
|
|
promoCodeSequence += 1;
|
|
return `${String(promoCodeSequence).padStart(8, "0")}-0000-4000-8000-000000000001`;
|
|
},
|
|
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
|
|
storageRead(ops) {
|
|
return ops.map(op => promoStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean);
|
|
},
|
|
storageWrite(ops) {
|
|
for (const op of ops) {
|
|
const key = `${op.collection}:${op.userId}:${op.key}`;
|
|
const previous = promoStorage.get(key);
|
|
if (op.version === "*" && previous) throw new Error("version conflict");
|
|
if (op.version && op.version !== "*" && (!previous || previous.version !== op.version)) throw new Error("version conflict");
|
|
promoVersion += 1;
|
|
promoStorage.set(key, { collection: op.collection, userId: op.userId, key: op.key, value: JSON.parse(JSON.stringify(op.value)), version: String(promoVersion) });
|
|
}
|
|
},
|
|
storageList(userId, collection, limit, cursor) {
|
|
const objects = Array.from(promoStorage.values())
|
|
.filter(record => record.userId === userId && record.collection === collection)
|
|
.slice(0, limit)
|
|
.map(record => ({ key: record.key, value: JSON.parse(JSON.stringify(record.value)) }));
|
|
return { objects, cursor: "" };
|
|
},
|
|
storageDelete(ops) {
|
|
for (const op of ops) {
|
|
const key = `${op.collection}:${op.userId}:${op.key}`;
|
|
if (beforePromoDelete) beforePromoDelete(key);
|
|
const previous = promoStorage.get(key);
|
|
if (!previous || op.version !== previous.version) throw new Error("version conflict");
|
|
promoStorage.delete(key);
|
|
}
|
|
}
|
|
};
|
|
assert.throws(() => context.rpcCreateUnlockCode({ userId: "pilot-a" }, logger, promoNk, JSON.stringify({ reward: "all_maps", max_uses: 1 })), /server-to-server/);
|
|
assert.throws(() => context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps", note: "x".repeat(161) })), /160 characters/);
|
|
const createdPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps", max_uses: 2, note: "Für den Multiplayer-Test" })));
|
|
assert.strictEqual(createdPromo.accepted, true);
|
|
assert.strictEqual(createdPromo.code.length, 24);
|
|
assert.strictEqual(createdPromo.note, "Für den Multiplayer-Test");
|
|
assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0].status, "open");
|
|
assert.throws(() => context.rpcListUnlockCodes({ userId: "pilot-a" }, logger, promoNk, "{}"), /server-to-server/);
|
|
const redeemedPromo = JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-a" }, logger, promoNk, JSON.stringify({ code: createdPromo.code })));
|
|
assert.strictEqual(redeemedPromo.accepted, true);
|
|
assert.strictEqual(redeemedPromo.all_maps, true);
|
|
assert.strictEqual(redeemedPromo.all_towers, false);
|
|
const partiallyRedeemed = JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0];
|
|
assert.strictEqual(partiallyRedeemed.status, "partial");
|
|
assert.strictEqual(partiallyRedeemed.uses, 1);
|
|
assert.strictEqual(partiallyRedeemed.code, createdPromo.code);
|
|
assert.strictEqual(partiallyRedeemed.note, "Für den Multiplayer-Test");
|
|
assert.strictEqual(JSON.parse(context.rpcGetPromoUnlocks({ userId: "pilot-promo-a" }, logger, promoNk, "{}")).all_maps, true);
|
|
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-a" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).already_redeemed, true);
|
|
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-b" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).accepted, true);
|
|
assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0].status, "redeemed");
|
|
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-c" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).error, "exhausted");
|
|
const usedCodeKey = promoNk.sha256Hash(createdPromo.code);
|
|
assert.throws(() => context.rpcDeleteUnlockCode({ userId: "pilot-a" }, logger, promoNk, JSON.stringify({ code_key: usedCodeKey })), /server-to-server/);
|
|
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: usedCodeKey }))).error, "already_used");
|
|
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: "bad-key" }))).error, "invalid");
|
|
const unusedPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_towers" })));
|
|
const unusedCodeKey = promoNk.sha256Hash(unusedPromo.code);
|
|
assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes.find(entry => entry.code === unusedPromo.code).code_key, unusedCodeKey);
|
|
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: unusedCodeKey }))).accepted, true);
|
|
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: unusedCodeKey }))).error, "not_found");
|
|
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-d" }, logger, promoNk, JSON.stringify({ code: unusedPromo.code }))).error, "invalid");
|
|
const racingPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps" })));
|
|
const racingCodeKey = promoNk.sha256Hash(racingPromo.code);
|
|
beforePromoDelete = key => {
|
|
beforePromoDelete = null;
|
|
const previous = promoStorage.get(key);
|
|
promoVersion += 1;
|
|
promoStorage.set(key, { ...previous, version: String(promoVersion), value: { ...previous.value, uses: 1, redeemed_users: ["pilot-race"] } });
|
|
};
|
|
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: racingCodeKey }))).error, "already_used");
|
|
assert.ok(promoStorage.has(`unlock_codes:${context.SYSTEM_USER_ID}:${racingCodeKey}`));
|
|
|
|
let leaderboardWrite = null;
|
|
const leaderboardNk = {
|
|
accountGetId() { return { user: { displayName: "Kommandant Ägir" } }; },
|
|
leaderboardRecordWrite(...args) {
|
|
leaderboardWrite = args;
|
|
return { rank: 1, score: args[3] };
|
|
}
|
|
};
|
|
const submittedRun = JSON.parse(context.rpcSubmitRun(
|
|
{ userId: "pilot-a", username: "technical_guest_id" },
|
|
{ warn() {} },
|
|
leaderboardNk,
|
|
JSON.stringify({ map: 0, difficulty: 1, mode: "mission", wave: 4, score: 4700, client_version: "test" })
|
|
));
|
|
assert.strictEqual(submittedRun.accepted, true);
|
|
assert.strictEqual(leaderboardWrite[2], "Kommandant Ägir");
|
|
assert.strictEqual(leaderboardWrite[5].displayName, "Kommandant Ägir");
|
|
|
|
let state = context.matchInit({}, logger, nk, { difficulty: 1 }).state;
|
|
state = context.matchJoin({}, logger, nk, dispatcher, 1, state, [presenceA]).state;
|
|
state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).state;
|
|
assert.strictEqual(state.started, false);
|
|
assert.strictEqual(state.slots["pilot-a"], 0);
|
|
assert.strictEqual(state.slots["pilot-b"], 1);
|
|
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.players[0].name === "Pilot A"));
|
|
assert.strictEqual(context.normalizeRoomCode(" ab-c12! "), "ABC12");
|
|
|
|
const privateMatch = context.matchInit({}, logger, nk, { difficulty: 2, mode: "private", roomCode: "AB23CD", hostUserId: "pilot-a" });
|
|
assert.strictEqual(privateMatch.label, "AB23CD");
|
|
assert.strictEqual(privateMatch.state.difficulty, 2);
|
|
|
|
const roomStorage = new Map();
|
|
const nkRooms = {
|
|
storageRead(ops) { return ops.map(op => roomStorage.get(op.key)).filter(Boolean); },
|
|
storageWrite(ops) { for (const op of ops) roomStorage.set(op.key, { value: op.value }); },
|
|
storageDelete(ops) { for (const op of ops) roomStorage.delete(op.key); },
|
|
matchCreate() { return "private-match-id"; }
|
|
};
|
|
const createdRoom = JSON.parse(context.rpcCreatePrivateRoom({ userId: "pilot-a" }, logger, nkRooms, JSON.stringify({ difficulty: 2 })));
|
|
assert.strictEqual(createdRoom.room_code.length, 6);
|
|
const joinedRoom = JSON.parse(context.rpcJoinPrivateRoom({ userId: "pilot-b" }, logger, nkRooms, JSON.stringify({ room_code: createdRoom.room_code.toLowerCase() })));
|
|
assert.strictEqual(joinedRoom.match_id, "private-match-id");
|
|
|
|
function message(sender, opCode, payload) {
|
|
return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) };
|
|
}
|
|
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 3, state, [message(presenceA, 14, { ready: true, name: "Kommandant A" })]).state;
|
|
assert.strictEqual(state.started, false);
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 4, state, [message(presenceB, 14, { ready: true, name: "Kommandant B" })]).state;
|
|
assert.strictEqual(state.started, true);
|
|
assert.strictEqual(state.players["pilot-a"].name, "Kommandant A");
|
|
assert.ok(sent.some(entry => entry.opCode === "label" && entry.data.open === false));
|
|
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 5, state, [
|
|
message(presenceA, 10, { tower_type: 0, x: 200, y: 200 }),
|
|
message(presenceB, 10, { tower_type: 1, x: 2760, y: 200 })
|
|
]).state;
|
|
assert.strictEqual(state.towers.length, 2);
|
|
assert.strictEqual(state.credits[0], 310);
|
|
assert.strictEqual(state.credits[1], 250);
|
|
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_builds[0], 1, "Accepted online tower builds should be counted on the server.");
|
|
assert.strictEqual(metricStorage.get("player_activity:pilot-b:summary").value.tower_builds[1], 1);
|
|
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 51, state,
|
|
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 }),
|
|
message(presenceB, 15, { password: "incorrect" }),
|
|
message(presenceB, 16, { amount: 500 })]).state;
|
|
assert.strictEqual(state.towers.length, 2, "Admin tower builds must be rejected before developer authorization.");
|
|
assert.strictEqual(state.credits[1], 250, "Unauthorized player must not be able to adjust test energy.");
|
|
assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "Wrong developer password must not authorize the player.");
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 52, state,
|
|
[message(presenceB, 15, { password: "Spring.3" })]).state;
|
|
assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "A non-admin account must not gain developer access even with the password.");
|
|
assert.strictEqual(context.DEV_ADMIN_USER_IDS["pilot-a"], undefined);
|
|
assert.strictEqual(context.DEV_ADMIN_USER_IDS["6605c44c-17a2-4cc4-bf3e-e772fd6229b0"], true);
|
|
context.DEV_ADMIN_USER_IDS["pilot-a"] = true;
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 53, state,
|
|
[message(presenceA, 15, { password: "Spring.3" })]).state;
|
|
assert.strictEqual(state.devAuthorized["pilot-a"], true, "Correct developer password should authorize this player for the match.");
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 54, state,
|
|
[message(presenceA, 16, { amount: 500 })]).state;
|
|
assert.strictEqual(state.credits[0], 810, "Authorized test energy addition should change server credits by 500.");
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 55, state,
|
|
[message(presenceA, 16, { amount: -500 })]).state;
|
|
assert.strictEqual(state.credits[0], 310, "Authorized test energy removal should change server credits by 500.");
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 56, state,
|
|
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 })]).state;
|
|
assert.ok(state.towers.some(tower => tower.type === 7 && tower.owner === 0), "Authorized player may build the admin turret anywhere on the co-op map.");
|
|
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_build_total, 1, "Admin towers are excluded from player statistics.");
|
|
const adminCombatState = {
|
|
balance: state.balance,
|
|
towers: [{ id: 99, type: 7, x: 2200, y: 600, owner: 0, level: 1, cooldown: 0, angle: 0 }],
|
|
enemies: [
|
|
{ id: 99, type: 8, x: 2250, y: 600, route_id: 1, path_index: 1, hp: 100000, max_hp: 100000, shield: true, shield_hp: 1000, shield_resistance: 0.28 },
|
|
{ id: 100, type: 8, x: 2250, y: 610, route_id: 0, path_index: 1, hp: 100000, max_hp: 100000, shield: true, shield_hp: 1000, shield_resistance: 0.28 }
|
|
],
|
|
shots: [], credits: [0, 0]
|
|
};
|
|
context.updateTowers(adminCombatState, 0.1);
|
|
assert.deepStrictEqual(adminCombatState.enemies.map(enemy => enemy.id), [100], "The admin turret must one-shot shielded enemies only on its own route.");
|
|
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 57, state, [message(presenceA, 13, {})]).state;
|
|
assert.strictEqual(state.wave, 1);
|
|
assert.strictEqual(state.waveActive, true);
|
|
assert.strictEqual(context.snapshot(state, 56).wave_types_remaining.length, state.spawnRemaining);
|
|
const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3));
|
|
assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3.");
|
|
for (let tick = 57; tick < 80; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
|
|
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
|
|
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
|
|
const compactSnapshot = context.snapshot(state, 79);
|
|
const untrimmedSnapshot = { ...compactSnapshot, towers: state.towers, enemies: state.enemies };
|
|
assert.ok(JSON.stringify(compactSnapshot).length < JSON.stringify(untrimmedSnapshot).length, "Frequent snapshots should omit simulation-only properties.");
|
|
assert.ok(compactSnapshot.towers.every(tower => !Object.hasOwn(tower, "cooldown")));
|
|
assert.ok(compactSnapshot.enemies.every(enemy => !Object.hasOwn(enemy, "shield_resistance") && !Object.hasOwn(enemy, "reward")));
|
|
assert.ok(state.towers.every(tower => Object.hasOwn(tower, "cooldown")), "Network serialization must not alter simulation state.");
|
|
|
|
const towerA = state.towers.find(tower => tower.owner === 0);
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 80, state, [message(presenceA, 11, { tower_id: towerA.id })]).state;
|
|
assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2);
|
|
state = context.matchLoop({}, logger, nk, dispatcher, 81, state, [message(presenceA, 12, { tower_id: towerA.id })]).state;
|
|
assert.ok(!state.towers.some(tower => tower.id === towerA.id));
|
|
|
|
state = context.matchLeave({}, logger, nk, dispatcher, 82, state, [presenceB]).state;
|
|
assert.strictEqual(state.started, true);
|
|
assert.strictEqual(state.pausedForReconnect, true);
|
|
assert.strictEqual(context.snapshot(state, 82).players[1].connected, false);
|
|
assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 83, state, presenceB, {}).accept, true);
|
|
state = context.matchJoin({}, logger, nk, dispatcher, 83, state, [presenceB]).state;
|
|
assert.strictEqual(state.pausedForReconnect, false);
|
|
assert.strictEqual(state.slots["pilot-b"], 1);
|
|
|
|
const bossState = context.initialState({ difficulty: 1 });
|
|
bossState.wave = 5;
|
|
bossState.waveSchedule = [{ type: 5, boss: true }];
|
|
const originalRandom = context.Math.random;
|
|
context.Math.random = () => 0;
|
|
context.spawnEnemy(bossState);
|
|
context.Math.random = originalRandom;
|
|
assert.ok(bossState.enemies[0].shield, "The first boss shield test needs a shielded ship.");
|
|
assert.ok(Math.abs(bossState.enemies[0].max_hp -
|
|
context.ENEMIES[5].hp * (1 + 4 * 0.20 + 2 * 0.09) * 3.25 * context.DIFFICULTIES[1].hp * 0.5,
|
|
) < 0.0001, "Wave-five mothership hull should be reduced by 50%.");
|
|
assert.ok(Math.abs(bossState.enemies[0].max_shield_hp - bossState.enemies[0].max_hp * 0.55) < 0.0001,
|
|
"The wave-five shield remains at its already-halved absolute strength while hull is reduced.");
|
|
const laterBossState = context.initialState({ difficulty: 1 });
|
|
laterBossState.wave = 10;
|
|
laterBossState.waveSchedule = [{ type: 8, boss: true }];
|
|
context.Math.random = () => 0;
|
|
context.spawnEnemy(laterBossState);
|
|
context.Math.random = originalRandom;
|
|
assert.strictEqual(laterBossState.enemies[0].max_shield_hp, laterBossState.enemies[0].max_hp * 0.55,
|
|
"Later bosses retain their original shield balance.");
|
|
|
|
const flakAimState = {
|
|
towers: [{ id: 1, type: 6, x: 0, y: 0, owner: 0, level: 1, cooldown: 1, angle: 0 }],
|
|
enemies: [{ id: 1, type: 0, x: 100, y: 0, hp: 100, shield: false }],
|
|
shots: []
|
|
};
|
|
context.updateTowers(flakAimState, 0.1);
|
|
assert.ok(Math.abs(flakAimState.towers[0].angle - Math.PI) < 0.0001,
|
|
"Flak visualization must include the 180-degree sprite orientation correction.");
|
|
|
|
const diagnosticStore = new Map();
|
|
const diagnosticNk = {
|
|
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
|
|
storageRead(ops) { return ops.map(op => diagnosticStore.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
|
storageWrite(ops) { for (const op of ops) diagnosticStore.set(`${op.collection}:${op.userId}:${op.key}`, { key: op.key, value: op.value }); },
|
|
storageList(userId, collection) {
|
|
return { objects: [...diagnosticStore.entries()].filter(([key]) => key.startsWith(`${collection}:${userId}:`)).map(([, object]) => object), cursor: "" };
|
|
},
|
|
storageDelete(ops) { for (const op of ops) diagnosticStore.delete(`${op.collection}:${op.userId}:${op.key}`); }
|
|
};
|
|
const diagnosticMatchId = "sample-match.123";
|
|
let diagState = context.initialState({ difficulty: 1 });
|
|
diagState = context.matchJoin({ matchId: diagnosticMatchId }, logger, diagnosticNk, dispatcher, 1, diagState,
|
|
[{ userId: hostId, username: "Host" }, { userId: friendId, username: "Guest" }]).state;
|
|
const report = { match_id: diagnosticMatchId, version: "0.26", records: [
|
|
{ event: "window", utc: "2026-10-10T13:00:00", snapshots: 90, gap_p95_ms: 103, player_name: "must_not_store" },
|
|
{ event: "end", utc: "2026-10-10T13:00:01", reason: "left_match" }
|
|
] };
|
|
const firstReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, JSON.stringify(report)));
|
|
const secondReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: friendId }, logger, diagnosticNk, JSON.stringify(report)));
|
|
assert.strictEqual(firstReport.round_id, secondReport.round_id, "Both players need one shared round ID.");
|
|
assert.strictEqual(firstReport.slot, 0);
|
|
assert.strictEqual(secondReport.slot, 1);
|
|
assert.strictEqual(JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: "stranger" }, logger, diagnosticNk, JSON.stringify(report))).error,
|
|
"not_participant", "A third account cannot attach data to the round.");
|
|
const listedReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk, "{}")).reports;
|
|
assert.strictEqual(listedReports.length, 2);
|
|
assert.ok(!JSON.stringify(listedReports).includes("must_not_store"), "The server must discard arbitrary client strings.");
|
|
const pairReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk,
|
|
JSON.stringify({ round_id: firstReport.round_id }))).reports;
|
|
assert.strictEqual(pairReports.length, 2);
|
|
assert.strictEqual(pairReports[0].round_id, pairReports[1].round_id);
|
|
assert.ok(!JSON.stringify(pairReports).includes("must_not_store"), "Detailed reports must also omit unapproved strings.");
|
|
assert.throws(() => context.rpcListOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, "{}"), /server-to-server/);
|
|
|
|
console.log("VOID_BASTION_SERVER_SIM_OK");
|