var GAME_VERSION = "0.23"; 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; 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]]); var PATHS = [LEFT_PATH, RIGHT_PATH]; var TOWERS = [ {cost:120,damage:22,range:190,cooldown:0.55}, {cost:180,damage:12,range:165,cooldown:0.18}, {cost:250,damage:65,range:260,cooldown:1.40} ]; var ENEMIES = [{hp:70,speed:105,reward:18},{hp:185,speed:72,reward:34},{hp:520,speed:43,reward:78}]; var DIFFICULTIES = [ {hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450}, {hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430}, {hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400} ]; function clampInteger(value, minimum, maximum) { var parsed = Math.floor(Number(value)); if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range"); return parsed; } function leaderboardId(mapIndex, difficulty, mode) { return mode + "_map" + mapIndex + "_difficulty" + difficulty; } function rpcServerInfo(ctx, logger, nk, payload) { return JSON.stringify({game:"void_bastion",version:GAME_VERSION,leaderboards:MAP_COUNT*DIFFICULTY_COUNT*VALID_MODES.length,authoritative_multiplayer:true,status:"online"}); } function rpcSubmitRun(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("authentication required"); var run; try { run = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); } var mapIndex = clampInteger(run.map,0,MAP_COUNT-1), difficulty = clampInteger(run.difficulty,0,DIFFICULTY_COUNT-1); var mode = String(run.mode || ""); if (VALID_MODES.indexOf(mode) < 0) throw new Error("invalid game mode"); var wave = clampInteger(run.wave,1,mode === "mission" ? 15 : 9999), score = clampInteger(run.score,0,1000000000); if (score > wave*5000+100000) throw new Error("implausible score"); var id = leaderboardId(mapIndex,difficulty,mode); var publicName = "PILOT"; try { var account = nk.accountGetId(ctx.userId); var storedName = account && account.user ? normalizeDisplayName(account.user.displayName) : ""; if (!validateDisplayName(storedName)) publicName = storedName; } catch (error) { logger.warn("Could not resolve display name for leaderboard user %s: %s", ctx.userId, error); } var metadata = {map:mapIndex,difficulty:difficulty,mode:mode,wave:wave,displayName:publicName,clientVersion:String(run.client_version||"unknown"),submittedAt:Date.now()}; var record = nk.leaderboardRecordWrite(id,ctx.userId,publicName,score,wave,metadata,null); return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave}); } function normalizeDisplayName(value) { return String(value || "").trim().replace(/\s+/g, " ").substring(0, 20); } function validateDisplayName(value) { var name = normalizeDisplayName(value); if (name.length < 3) return "name_too_short"; if (name.length > 20) return "name_too_long"; if (!/^[A-Za-z0-9 ÄÖÜäöüß_-]+$/.test(name)) return "invalid_characters"; var reserved = ["ADMIN","ADMINISTRATOR","GERGAMES","MODERATOR","SUPPORT","SYSTEM"]; if (reserved.indexOf(name.toUpperCase()) >= 0) return "reserved_name"; return ""; } function rpcUpdateDisplayName(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 name = normalizeDisplayName(request.display_name), errorCode = validateDisplayName(name); if (errorCode) return JSON.stringify({accepted:false,error:errorCode}); nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null); return JSON.stringify({accepted:true,display_name:name}); } function rpcDeleteAccount(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("authentication required"); nk.accountDeleteId(ctx.userId, false); return JSON.stringify({accepted:true}); } 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)))); return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty}); } 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,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: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;i0?((px-ax)*abx+(py-ay)*aby)/len:0; t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t)); } function validBuildPosition(state,slot,x,y) { var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5; if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||yPLAYFIELD_BOTTOM) return false; if (slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false; if (slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false; if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false; for (var r=0;r=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]=3) return false; var cost=upgradeCost(state.towers[i]); if (state.credits[slot]0&&state.wave%5===0&&state.spawnRemaining<=2,roll=Math.random(); var type=boss?2:(state.wave>=7&&roll>0.82?2:(state.wave>=3&&roll>0.53?1:0)); var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty],scale=(1+Math.max(0,state.wave-1)*0.115)*(boss?3.25:1)*d.hp; var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId]; state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss}); } function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); } function updateEnemies(state,delta) { for (var i=state.enemies.length-1;i>=0;i--) { var e=state.enemies[i],route=PATHS[e.route_id],target=route[e.path_index],dx=target[0]-e.x,dy=target[1]-e.y,dist=Math.sqrt(dx*dx+dy*dy); e.angle=Math.atan2(dy,dx); if (dist<=Math.max(9,e.speed*delta)) { e.x=target[0]; e.y=target[1]; e.path_index+=1; if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(1+e.type+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } } } else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; } } } function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); } function updateTowers(state,delta) { for (var i=0;ibest) { best=p; targetIndex=j; } } if (targetIndex<0) continue; var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+(t.type===0?Math.PI:0); if (t.cooldown>0) continue; var damage=towerDamage(t); if (t.type===2) { var tx=target.x,ty=target.y,targetId=target.id; for (var k=state.enemies.length-1;k>=0;k--) if (distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=76*76) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*0.45; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); } } else { target.hp-=damage; if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); } t.cooldown=towerCooldown(t); } } function updateWave(state,delta,dispatcher) { if (!state.waveActive||state.gameOver) return; if (state.spawnRemaining>0) { state.spawnTimer-=delta; if (state.spawnTimer<=0) { spawnEnemy(state); state.spawnRemaining-=1; state.spawnTimer=Math.max(0.24,(0.82-state.wave*0.025)*DIFFICULTIES[state.difficulty].spawnDelay); } } else if (!state.enemies.length) { state.waveActive=false; var bonus=Math.floor((55+state.wave*8)*DIFFICULTIES[state.difficulty].income); state.credits[0]+=bonus; state.credits[1]+=bonus; broadcastEvent(dispatcher,"wave_complete",{wave:state.wave,bonus:bonus}); } } function handleCommand(nk,dispatcher,state,message) { var slot=state.slots[message.sender.userId]; if (slot===undefined) return; var payload=parseMessage(nk,message); if (payload===null) return; var changed=false; if (message.opCode===OP_BUILD) changed=handleBuild(state,slot,payload); 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&&!state.started) { state.ready[message.sender.userId]=payload.ready!==false; var requestedName=normalizeDisplayName(payload.name||message.sender.username||"PILOT"); var name=validateDisplayName(requestedName) ? "PILOT" : requestedName; 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= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; } } else state.emptyTicks = 0; for (var i=0;i=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}; } 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