Release alpha 10 with bilingual UI and social co-op

This commit is contained in:
Emporex committed 2026-10-10 12:50:27 +02:00
1 parent 7d5cad7277
commit d53467baf2
22 files changed
+1714 -271

No files matched your search

+134
View File
@@ -2,6 +2,7 @@ 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 };
@@ -9,8 +10,10 @@ 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.");
@@ -18,6 +21,48 @@ 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.");
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 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.");
@@ -123,6 +168,89 @@ 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 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" } }; },
@@ -194,6 +322,12 @@ assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category ===
for (let tick = 7; tick < 30; 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, 29);
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, 30, state, [message(presenceA, 11, { tower_id: towerA.id })]).state;