Add private multiplayer room codes

This commit is contained in:
Emporex committed 2026-10-04 04:10:20 +02:00
1 parent 7362ca0f02
commit 19b1a67773
8 files changed
+341 -24

No files matched your search

+56 -4
View File
@@ -3,6 +3,8 @@ var MAP_COUNT = 3;
var DIFFICULTY_COUNT = 3;
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
var MATCH_MODULE = "void_bastion_coop";
var ROOM_COLLECTION = "private_rooms";
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;
@@ -46,6 +48,47 @@ function rpcSubmitRun(ctx, logger, nk, payload) {
return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave});
}
function normalizeRoomCode(value) {
return String(value || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6);
}
function generateRoomCode() {
var alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789", code = "";
for (var i = 0; i < 6; i++) code += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
return code;
}
function privateRoomRecord(nk, code) {
var records = nk.storageRead([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID}]);
return records.length ? records[0] : null;
}
function deletePrivateRoomRecord(nk, code) {
if (code) nk.storageDelete([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID}]);
}
function rpcCreatePrivateRoom(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var difficulty = Math.max(0, Math.min(2, Math.floor(Number(request.difficulty || 1))));
var code = "";
for (var attempt = 0; attempt < 24; attempt++) {
var candidate = generateRoomCode();
if (!privateRoomRecord(nk, candidate)) { code = candidate; break; }
}
if (!code) throw new Error("room code allocation failed");
var matchId = nk.matchCreate(MATCH_MODULE, {difficulty:difficulty,mode:"private",roomCode:code,hostUserId:ctx.userId});
nk.storageWrite([{collection:ROOM_COLLECTION,key:code,userId:SYSTEM_USER_ID,value:{match_id:matchId,host_user_id:ctx.userId,created_at:Date.now()},permissionRead:0,permissionWrite:0}]);
return JSON.stringify({created:true,room_code:code,match_id:matchId});
}
function rpcJoinPrivateRoom(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var code = normalizeRoomCode(request.room_code);
if (code.length !== 6) return JSON.stringify({found:false,error:"invalid_room_code"});
var record = privateRoomRecord(nk, code);
if (!record || !record.value || !record.value.match_id) return JSON.stringify({found:false,error:"room_not_found"});
return JSON.stringify({found:true,room_code:code,match_id:String(record.value.match_id)});
}
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))));
@@ -54,9 +97,10 @@ 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:{},started:false,gameOver:false,difficulty:difficulty,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
return {presences:{},slots:{},started:false,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,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
}
function matchInit(ctx,logger,nk,params) { return {state:initialState(params||{}),tickRate:TICK_RATE,label:JSON.stringify({game:"void_bastion",mode:"coop",open:true})}; }
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)}; }
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:Object.keys(state.presences).length<2}; }
function firstFreeSlot(state) {
var used={},ids=Object.keys(state.slots);
@@ -71,13 +115,15 @@ function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.ty
function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
for (var i=0;i<presences.length;i++) { var p=presences[i]; state.presences[p.userId]=p; if (state.slots[p.userId]===undefined) state.slots[p.userId]=firstFreeSlot(state); }
state.started=Object.keys(state.presences).length===2;
state.emptyTicks=0;
dispatcher.matchLabelUpdate(matchLabel(state));
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
broadcastSnapshot(dispatcher,state,tick);
return {state:state};
}
function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
for (var i=0;i<presences.length;i++) delete state.presences[presences[i].userId];
state.started=false; broadcastEvent(dispatcher,"partner_left",{}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
state.started=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
}
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
function towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
@@ -174,18 +220,24 @@ 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) {
if (!Object.keys(state.presences).length) {
state.emptyTicks += 1;
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
} else state.emptyTicks = 0;
for (var i=0;i<messages.length;i++) handleCommand(nk,dispatcher,state,messages[i]);
if (state.started&&!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);
return {state:state};
}
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); 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}; }
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
function InitModule(ctx,logger,nk,initializer) {
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
initializer.registerRpc("server_info",rpcServerInfo);
initializer.registerRpc("submit_run",rpcSubmitRun);
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
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);
+23
View File
@@ -12,6 +12,11 @@ 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") };
@@ -25,6 +30,24 @@ state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).sta
assert.strictEqual(state.started, true);
assert.strictEqual(state.slots["pilot-a"], 0);
assert.strictEqual(state.slots["pilot-b"], 1);
assert.ok(sent.some(entry => entry.opCode === "label" && entry.data.open === false));
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)) };