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

+6 -1
View File
@@ -28,9 +28,14 @@ Für eine öffentliche Testversion wird Port 7350 später über Nginx Proxy Mana
Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. Vor einem Versionswechsel werden Datenbank und Nakama-Appdata gesichert und die offiziellen Migrationshinweise geprüft.
## Freunde und Einladungen
Freundschaftsanfragen, bestätigte Freundschaften und Online-Präsenz verwenden die eingebauten Nakama-Funktionen. Die app-eigene RPC `invite_friend` nimmt ausschließlich die Freundes-ID und einen sechsstelligen privaten Raumcode entgegen. Sie prüft, dass der Absender den Raum erstellt hat und eine gegenseitige Freundschaft besteht. Je Empfänger gilt ein Mindestabstand von 15 Sekunden. Die Benachrichtigung ist nicht persistent und enthält nur Absendername und Raumcode; ein Chat oder frei eingebbarer Einladungstext existiert nicht. Die Funktion muss zusammen mit dem aktualisierten Godot-Client ausgerollt werden.
## Private Statistik-RPCs
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
- `private_stats`: nur als Server-zu-Server-Aufruf mit `NAKAMA_HTTP_KEY`; liefert die zusammengefassten Werte für die interne Homepage.
- `create_unlock_code`, `list_unlock_codes` und `delete_unlock_code`: nur Server-zu-Server mit `NAKAMA_HTTP_KEY`; verwalten private Freischaltcodes inklusive Einlösungsstatus und optionaler Admin-Bemerkung. Löschen ist nur bei null Einlösungen möglich; eine versionsgebundene Löschung schützt vor einer gleichzeitigen Einlösung.
Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden.
Der Code-Lookup verwendet einen SHA-256-Schlüssel. Für die private Admin-Übersicht wird der Klartextcode zusätzlich in einem Nakama-Speicherobjekt mit `permissionRead:0` und `permissionWrite:0` gehalten; Spielclients können dieses Objekt nicht lesen. Bemerkungen sind auf 160 Zeichen beschränkt. Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden.
+198 -7
View File
@@ -1,4 +1,4 @@
var GAME_VERSION = "0.23";
var GAME_VERSION = "0.25";
var MAP_COUNT = 7;
var DIFFICULTY_COUNT = 3;
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
@@ -8,10 +8,14 @@ var ACTIVITY_COLLECTION = "player_activity";
var ACTIVITY_KEY = "summary";
var STATS_COLLECTION = "internal_stats";
var STATS_KEY = "global";
var PROMO_CODE_COLLECTION = "unlock_codes";
var PROMO_UNLOCK_COLLECTION = "player_unlocks";
var PROMO_UNLOCK_KEY = "website_codes";
var PROMO_ATTEMPT_COLLECTION = "unlock_attempts";
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
var TICK_RATE = 10, SNAPSHOT_TICKS = 2, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 92, PLAYFIELD_BOTTOM = 758;
var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 84, PLAYFIELD_BOTTOM = 758;
var LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]];
var RIGHT_PATH = [];
for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]);
@@ -22,7 +26,8 @@ var TOWERS = [
{cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"},
{cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
{cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1},
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70}
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70},
{cost:210,damage:20,range:230,cooldown:0.42,targetClass:"air"}
];
var ENEMIES = [
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
@@ -140,6 +145,137 @@ function rpcPrivateStats(ctx, logger, nk, payload) {
var stats = record && record.value ? record.value : emptyStats(Date.now());
return JSON.stringify(stats);
}
function rpcCreateUnlockCode(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var reward = String(request.reward || "");
if (["all_towers", "all_maps", "all"].indexOf(reward) < 0) throw new Error("invalid unlock reward");
var maxUses = clampInteger(request.max_uses === undefined ? 1 : request.max_uses, 1, 50000);
var note = String(request.note || "").trim();
if (note.length > 160) throw new Error("note exceeds 160 characters");
for (var attempt = 0; attempt < 5; attempt++) {
var code = nk.uuidv4().replace(/-/g, "").substring(0, 24).toUpperCase();
var codeKey = nk.sha256Hash(code);
if (readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID)) continue;
nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:{code:code,reward:reward,note:note,max_uses:maxUses,uses:0,redeemed_users:[],created_at:Date.now()},permissionRead:0,permissionWrite:0,version:"*"}]);
return JSON.stringify({accepted:true,code:code,reward:reward,note:note,max_uses:maxUses,uses:0,status:"open"});
}
throw new Error("could not allocate unique unlock code");
}
function rpcListUnlockCodes(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var codes = [], cursor = "", pages = 0;
do {
var result = nk.storageList(SYSTEM_USER_ID, PROMO_CODE_COLLECTION, 100, cursor);
var objects = Array.isArray(result) ? result : (result && Array.isArray(result.objects) ? result.objects : []);
for (var i = 0; i < objects.length; i++) {
var object = objects[i], value = object && object.value ? object.value : {};
var uses = Math.max(0, Math.floor(Number(value.uses || 0)));
var maxUses = Math.max(1, Math.floor(Number(value.max_uses || 1)));
codes.push({
code:String(value.code || ""),
code_key:String(object && object.key ? object.key : ""),
code_id:String(object && object.key ? object.key : "").slice(-8),
reward:String(value.reward || ""),
note:String(value.note || ""),
uses:uses,
max_uses:maxUses,
created_at:Number(value.created_at || 0),
status:uses >= maxUses ? "redeemed" : (uses > 0 ? "partial" : "open")
});
}
cursor = result && !Array.isArray(result) ? String(result.cursor || "") : "";
pages++;
} while (cursor && pages < 10);
codes.sort(function(a, b) { return b.created_at - a.created_at; });
return JSON.stringify({codes:codes,truncated:!!cursor});
}
function rpcDeleteUnlockCode(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var codeKey = String(request.code_key || "").toLowerCase();
if (!/^[a-f0-9]{64}$/.test(codeKey)) return JSON.stringify({accepted:false,error:"invalid"});
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID);
if (!record || !record.value) return JSON.stringify({accepted:false,error:"not_found"});
var value = record.value;
if (Number(value.uses || 0) !== 0 || (Array.isArray(value.redeemed_users) && value.redeemed_users.length > 0)) {
return JSON.stringify({accepted:false,error:"already_used"});
}
if (!record.version) throw new Error("unlock code has no storage version");
try {
nk.storageDelete([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,version:record.version}]);
return JSON.stringify({accepted:true});
} catch (error) {
if (attempt === 2) throw error;
}
}
return JSON.stringify({accepted:false,error:"temporary"});
}
function allowPromoAttempt(ctx, nk) {
var now = Date.now(), key = "user_" + ctx.userId;
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_ATTEMPT_COLLECTION, key, SYSTEM_USER_ID);
var value = record && record.value ? record.value : {window_started:now,count:0};
if (now - Number(value.window_started || 0) >= 5 * 60 * 1000) value = {window_started:now,count:0};
if (Number(value.count || 0) >= 10) return false;
value.count = Number(value.count || 0) + 1;
var write = {collection:PROMO_ATTEMPT_COLLECTION,key:key,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"};
try { nk.storageWrite([write]); return true; }
catch (error) { if (attempt === 2) return false; }
}
return false;
}
function writePromoUnlocks(nk, userId, reward) {
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, userId);
var value = record && record.value ? record.value : {all_towers:false,all_maps:false};
if (reward === "all_towers" || reward === "all") value.all_towers = true;
if (reward === "all_maps" || reward === "all") value.all_maps = true;
var write = {collection:PROMO_UNLOCK_COLLECTION,key:PROMO_UNLOCK_KEY,userId:userId,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"};
try { nk.storageWrite([write]); return value; }
catch (error) { if (attempt === 2) throw error; }
}
throw new Error("could not save player unlocks");
}
function rpcRedeemUnlockCode(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
if (!allowPromoAttempt(ctx, nk)) return JSON.stringify({accepted:false,error:"rate_limited"});
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid"}); }
var code = String(request.code || "").toUpperCase().replace(/[\s-]/g, "");
if (!/^[A-F0-9]{24}$/.test(code)) return JSON.stringify({accepted:false,error:"invalid"});
var codeKey = nk.sha256Hash(code);
for (var attempt = 0; attempt < 4; attempt++) {
var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID);
if (!record || !record.value) return JSON.stringify({accepted:false,error:"invalid"});
var value = record.value;
var redeemed = Array.isArray(value.redeemed_users) ? value.redeemed_users : [];
if (redeemed.indexOf(ctx.userId) >= 0) {
var existingUnlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || ""));
return JSON.stringify({accepted:true,already_redeemed:true,reward:value.reward,all_towers:!!existingUnlocks.all_towers,all_maps:!!existingUnlocks.all_maps});
}
if (Number(value.uses || 0) >= Number(value.max_uses || 1)) return JSON.stringify({accepted:false,error:"exhausted"});
value.redeemed_users = redeemed.concat([ctx.userId]);
value.uses = Number(value.uses || 0) + 1;
try {
nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record.version}]);
var unlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || ""));
return JSON.stringify({accepted:true,reward:value.reward,all_towers:!!unlocks.all_towers,all_maps:!!unlocks.all_maps});
} catch (error) {
if (attempt === 3) throw error;
}
}
return JSON.stringify({accepted:false,error:"temporary"});
}
function rpcGetPromoUnlocks(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, ctx.userId);
var value = record && record.value ? record.value : {all_towers:false,all_maps:false};
return JSON.stringify({accepted:true,all_towers:!!value.all_towers,all_maps:!!value.all_maps});
}
function rpcSubmitRun(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var run;
@@ -233,6 +369,40 @@ function rpcJoinPrivateRoom(ctx, logger, nk, payload) {
return JSON.stringify({found:true,room_code:code,match_id:String(record.value.match_id)});
}
function rpcInviteFriend(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); }
var friendId = String(request.friend_id || ""), code = normalizeRoomCode(request.room_code);
if (!/^[0-9a-f-]{36}$/i.test(friendId) || code.length !== 6 || friendId === ctx.userId)
return JSON.stringify({accepted:false,error:"invalid_invite"});
var room = privateRoomRecord(nk, code);
if (!room || !room.value || room.value.host_user_id !== ctx.userId)
return JSON.stringify({accepted:false,error:"not_room_host"});
var mutual = false, cursor = "";
for (var page = 0; page < 10 && !mutual; page++) {
var result = nk.friendsList(ctx.userId, 100, 0, cursor);
var list = Array.isArray(result) ? result : (result && Array.isArray(result.friends) ? result.friends : []);
for (var i = 0; i < list.length; i++) {
if (list[i].state === 0 && list[i].user && String(list[i].user.userId || list[i].user.id || "") === friendId) { mutual = true; break; }
}
cursor = result && result.cursor ? result.cursor : "";
if (!cursor) break;
}
if (!mutual) return JSON.stringify({accepted:false,error:"not_mutual_friends"});
var rateKey = "invite_" + friendId;
var previous = readStorageRecord(nk, "friend_invite_limits", rateKey, ctx.userId);
var now = Date.now();
if (previous && previous.value && now - Number(previous.value.sent_at || 0) < 15000)
return JSON.stringify({accepted:false,error:"invite_cooldown"});
var senderName = String(ctx.username || "Pilot");
var senders = nk.usersGetId([ctx.userId]);
if (senders.length && senders[0].displayName) senderName = String(senders[0].displayName);
nk.storageWrite([{collection:"friend_invite_limits",key:rateKey,userId:ctx.userId,value:{sent_at:now},permissionRead:0,permissionWrite:0}]);
nk.notificationsSend([{userId:friendId,subject:"Void Bastion Invite",content:{room_code:code,from:senderName.slice(0,32)},code:101,senderId:ctx.userId,persistent:false}]);
return JSON.stringify({accepted:true});
}
function matchmakerMatched(ctx, logger, nk, matches) {
var difficulty = 1;
if (matches.length && matches[0].properties && matches[0].properties.difficulty !== undefined) difficulty = Math.max(0,Math.min(2,Math.floor(Number(matches[0].properties.difficulty))));
@@ -241,7 +411,7 @@ function matchmakerMatched(ctx, logger, nk, matches) {
function initialState(params) {
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
var credits = DIFFICULTIES[difficulty].startCredits;
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0};
}
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
@@ -257,9 +427,15 @@ function snapshot(state,tick) {
var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined};
}
return {version:GAME_VERSION, tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:state.towers,enemies:state.enemies};
var towers=state.towers.map(function(tower) {
return {id:tower.id,x:tower.x,y:tower.y,type:tower.type,level:tower.level,owner:tower.owner,angle:Math.round(tower.angle*1000)/1000};
});
var enemies=state.enemies.map(function(enemy) {
return {id:enemy.id,type:enemy.type,x:Math.round(enemy.x*10)/10,y:Math.round(enemy.y*10)/10,path_index:enemy.path_index,route_id:enemy.route_id,hp:Math.round(enemy.hp*10)/10,max_hp:Math.round(enemy.max_hp*10)/10,angle:Math.round(enemy.angle*1000)/1000,boss:enemy.boss,shield:enemy.shield,shield_hp:Math.round(enemy.shield_hp*10)/10,max_shield_hp:Math.round(enemy.max_shield_hp*10)/10};
});
return {version:GAME_VERSION,tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:towers,enemies:enemies,shots:state.shots||[]};
}
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; }
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.shots=[]; state.lastSnapshotTick=tick; }
function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); }
function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
var rejoined=false;
@@ -386,6 +562,7 @@ function updateEnemies(state,delta) {
}
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
function updateTowers(state,delta) {
state.shots=state.shots||[];
for (var i=0;i<state.towers.length;i++) {
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
for (var j=0;j<state.enemies.length;j++) {
@@ -398,6 +575,7 @@ function updateTowers(state,delta) {
if (targetIndex<0) continue;
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
if (t.cooldown>0) continue;
state.shots.push({tower_id:t.id,type:t.type,from_x:t.x,from_y:t.y,to_x:target.x,to_y:target.y});
var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1);
if (t.type===5) {
var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
@@ -444,6 +622,11 @@ function handleCommand(nk,dispatcher,state,message) {
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
}
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
var loopStartMs=Date.now();
if (state.lastLoopStartMs && loopStartMs-state.lastLoopStartMs>250 && logger.warn)
logger.warn("ONLINE DIAG tick_gap_ms=%d tick=%d",loopStartMs-state.lastLoopStartMs,tick);
state.lastLoopStartMs=loopStartMs;
state.shots=[];
if (!Object.keys(state.presences).length) {
state.emptyTicks += 1;
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
@@ -452,6 +635,8 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
if (state.pausedForReconnect&&tick>=state.reconnectDeadlineTick) { state.gameOver=true; state.pausedForReconnect=false; state.waveActive=false; broadcastEvent(dispatcher,"partner_timeout",{}); }
if (state.started&&!state.pausedForReconnect&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); }
if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick);
var loopMs=Date.now()-loopStartMs;
if (loopMs>70 && logger.warn) logger.warn("ONLINE DIAG loop_ms=%d tick=%d enemies=%d towers=%d",loopMs,tick,state.enemies.length,state.towers.length);
return {state:state};
}
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { deletePrivateRoomRecord(nk,state.roomCode); broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); return {state:state}; }
@@ -462,11 +647,17 @@ function InitModule(ctx,logger,nk,initializer) {
initializer.registerRpc("server_info",rpcServerInfo);
initializer.registerRpc("record_activity",rpcRecordActivity);
initializer.registerRpc("private_stats",rpcPrivateStats);
initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode);
initializer.registerRpc("list_unlock_codes",rpcListUnlockCodes);
initializer.registerRpc("delete_unlock_code",rpcDeleteUnlockCode);
initializer.registerRpc("redeem_unlock_code",rpcRedeemUnlockCode);
initializer.registerRpc("get_promo_unlocks",rpcGetPromoUnlocks);
initializer.registerRpc("submit_run",rpcSubmitRun);
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
initializer.registerRpc("delete_account",rpcDeleteAccount);
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
initializer.registerRpc("invite_friend",rpcInviteFriend);
initializer.registerMatchmakerMatched(matchmakerMatched);
initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal});
logger.info("Void Bastion online runtime %s loaded",GAME_VERSION);
+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;