Add ready lobby and reconnect support

This commit is contained in:
Emporex committed 2026-10-04 04:31:52 +02:00
1 parent 19b1a67773
commit 32594c7f81
8 files changed
+215 -36

No files matched your search

+33 -10
View File
@@ -1,4 +1,4 @@
var GAME_VERSION = "0.20";
var GAME_VERSION = "0.21";
var MAP_COUNT = 3;
var DIFFICULTY_COUNT = 3;
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
@@ -97,24 +97,37 @@ 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,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};
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,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick: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)}; }
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:Object.keys(state.presences).length<2}; }
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; }
function firstFreeSlot(state) {
var used={},ids=Object.keys(state.slots);
for (var i=0;i<ids.length;i++) used[state.slots[ids[i]]]=true;
return used[0] ? 1 : 0;
}
function snapshot(state,tick) {
return {version:GAME_VERSION,tick:tick,started:state.started,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,towers:state.towers,enemies:state.enemies};
var lobby=[null,null],ids=Object.keys(state.slots);
for (var i=0;i<ids.length;i++) {
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,towers:state.towers,enemies:state.enemies};
}
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); 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) {
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;
var rejoined=false;
for (var i=0;i<presences.length;i++) {
var p=presences[i];
if (state.slots[p.userId]!==undefined) rejoined=true;
state.presences[p.userId]=p;
if (state.slots[p.userId]===undefined) state.slots[p.userId]=firstFreeSlot(state);
if (state.ready[p.userId]===undefined) state.ready[p.userId]=false;
state.players[p.userId]={name:String(p.username||"PILOT").substring(0,24)};
}
if (state.started&&Object.keys(state.presences).length===2) { state.pausedForReconnect=false; state.reconnectDeadlineTick=0; if (rejoined) broadcastEvent(dispatcher,"partner_rejoined",{}); }
state.emptyTicks=0;
dispatcher.matchLabelUpdate(matchLabel(state));
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
@@ -122,8 +135,9 @@ function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
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; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
for (var i=0;i<presences.length;i++) { delete state.presences[presences[i].userId]; state.ready[presences[i].userId]=false; }
if (state.started) { state.pausedForReconnect=true; state.reconnectDeadlineTick=tick+TICK_RATE*60; }
dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); 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); }
@@ -216,7 +230,15 @@ function handleCommand(nk,dispatcher,state,message) {
else if (message.opCode===OP_UPGRADE) changed=handleUpgrade(state,slot,payload);
else if (message.opCode===OP_SELL) changed=handleSell(state,slot,payload);
else if (message.opCode===OP_START_WAVE) changed=startWave(state);
else if (message.opCode===OP_READY) changed=true;
else if (message.opCode===OP_READY&&!state.started) {
state.ready[message.sender.userId]=payload.ready!==false;
var name=String(payload.name||message.sender.username||"PILOT").trim().substring(0,24);
state.players[message.sender.userId]={name:name||"PILOT"};
var ids=Object.keys(state.slots),allReady=ids.length===2&&Object.keys(state.presences).length===2;
for (var i=0;i<ids.length;i++) if (!state.ready[ids[i]]||state.presences[ids[i]]===undefined) allReady=false;
if (allReady) { state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{}); }
changed=true;
}
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
}
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
@@ -225,7 +247,8 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
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 (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);
return {state:state};
}
+21 -5
View File
@@ -27,10 +27,10 @@ const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" };
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, true);
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 === "label" && entry.data.open === false));
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" });
@@ -53,7 +53,14 @@ function message(sender, opCode, payload) {
return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) };
}
state = context.matchLoop({}, logger, nk, dispatcher, 3, state, [
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;
@@ -61,10 +68,10 @@ assert.strictEqual(state.towers.length, 2);
assert.strictEqual(state.credits[0], 310);
assert.strictEqual(state.credits[1], 250);
state = context.matchLoop({}, logger, nk, dispatcher, 4, state, [message(presenceA, 13, {})]).state;
state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state;
assert.strictEqual(state.wave, 1);
assert.strictEqual(state.waveActive, true);
for (let tick = 5; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
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));
@@ -74,4 +81,13 @@ assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2);
state = context.matchLoop({}, logger, nk, dispatcher, 31, 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, 32, state, [presenceB]).state;
assert.strictEqual(state.started, true);
assert.strictEqual(state.pausedForReconnect, true);
assert.strictEqual(context.snapshot(state, 32).players[1].connected, false);
assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 33, state, presenceB, {}).accept, true);
state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).state;
assert.strictEqual(state.pausedForReconnect, false);
assert.strictEqual(state.slots["pilot-b"], 1);
console.log("VOID_BASTION_SERVER_SIM_OK");