feat: initialize TRXTD browser tower defense
This commit is contained in:
commit
c4347f8420
34 changed files with 8302 additions and 0 deletions
258
server/server.mjs
Normal file
258
server/server.mjs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* TRXTD multiplayer relay server with security hardening:
|
||||
* - WebSocket maxPayload limit (prevents DoS)
|
||||
* - Heartbeat (ping/pong) to clean dead sockets
|
||||
* - Rate-limiting per connection (action flood protection)
|
||||
* - Strict message schema validation
|
||||
* - Inactive room cleanup timeout
|
||||
*/
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
const PORT = Number(process.env.PORT || 3001)
|
||||
const MAX_PAYLOAD_BYTES = 4096 // 4 KB is plenty for simple game actions
|
||||
const MAX_ACTIONS_PER_SEC = 30
|
||||
const ROOM_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes max room life
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: PORT,
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
})
|
||||
|
||||
/** code -> { code, mode, created: number, seq: number, players: [{ ws, id, name, lastAct: number, actCount: number }] } */
|
||||
const rooms = new Map()
|
||||
|
||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
function makeCode() {
|
||||
let code
|
||||
let attempts = 0
|
||||
do {
|
||||
code = Array.from({ length: 4 }, () => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]).join('')
|
||||
attempts++
|
||||
if (attempts > 1000) break
|
||||
} while (rooms.has(code))
|
||||
return code
|
||||
}
|
||||
|
||||
function sanitizeName(raw) {
|
||||
if (typeof raw !== 'string') return 'Spieler'
|
||||
// keep alphanumeric, spaces, dashes and common accents, max 12 chars
|
||||
const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12)
|
||||
return clean || 'Spieler'
|
||||
}
|
||||
|
||||
function send(ws, obj) {
|
||||
if (ws && ws.readyState === 1) {
|
||||
try {
|
||||
ws.send(JSON.stringify(obj))
|
||||
} catch {
|
||||
/* ignore socket send failure */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function roomOf(ws) {
|
||||
const code = ws._room
|
||||
return code ? rooms.get(code) : undefined
|
||||
}
|
||||
|
||||
function broadcast(room, obj, exceptWs) {
|
||||
for (const p of room.players) {
|
||||
if (p.ws !== exceptWs) send(p.ws, obj)
|
||||
}
|
||||
}
|
||||
|
||||
function playerInfo(room) {
|
||||
return room.players.map((p) => ({ id: p.id, name: p.name }))
|
||||
}
|
||||
|
||||
function leaveRoom(ws) {
|
||||
const room = roomOf(ws)
|
||||
if (!room) return
|
||||
room.players = room.players.filter((p) => p.ws !== ws)
|
||||
ws._room = undefined
|
||||
if (room.players.length === 0) {
|
||||
rooms.delete(room.code)
|
||||
} else {
|
||||
broadcast(room, { t: 'peer-left' })
|
||||
}
|
||||
}
|
||||
|
||||
function validateAction(a) {
|
||||
if (!a || typeof a !== 'object') return false
|
||||
const type = a.type
|
||||
switch (type) {
|
||||
case 'build':
|
||||
return (
|
||||
['arrow', 'cannon', 'frost', 'tesla', 'laser'].includes(a.kind) &&
|
||||
Number.isInteger(a.tx) &&
|
||||
a.tx >= 0 &&
|
||||
a.tx < 20 &&
|
||||
Number.isInteger(a.ty) &&
|
||||
a.ty >= 0 &&
|
||||
a.ty < 11
|
||||
)
|
||||
case 'upgrade':
|
||||
case 'sell':
|
||||
return Number.isInteger(a.towerId) && a.towerId > 0
|
||||
case 'targeting':
|
||||
return (
|
||||
Number.isInteger(a.towerId) &&
|
||||
a.towerId > 0 &&
|
||||
['first', 'last', 'strong', 'close'].includes(a.mode)
|
||||
)
|
||||
case 'obstacle':
|
||||
return Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11
|
||||
case 'wave':
|
||||
case 'rush':
|
||||
return true
|
||||
case 'speed':
|
||||
return [1, 2, 3].includes(a.s)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat interval to drop unresponsive clients
|
||||
const pingInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [code, room] of rooms.entries()) {
|
||||
if (now - room.created > ROOM_TIMEOUT_MS) {
|
||||
for (const p of room.players) {
|
||||
send(p.ws, { t: 'error', msg: 'Raum-Zeitüberschreitung.' })
|
||||
p.ws._room = undefined
|
||||
}
|
||||
rooms.delete(code)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) {
|
||||
leaveRoom(ws)
|
||||
return ws.terminate()
|
||||
}
|
||||
ws.isAlive = false
|
||||
try {
|
||||
ws.ping()
|
||||
} catch {
|
||||
leaveRoom(ws)
|
||||
}
|
||||
})
|
||||
}, 15000)
|
||||
|
||||
wss.on('close', () => {
|
||||
clearInterval(pingInterval)
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.isAlive = true
|
||||
ws._actBucket = { count: 0, resetAt: Date.now() + 1000 }
|
||||
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
ws.isAlive = true
|
||||
if (typeof data !== 'string' && !Buffer.isBuffer(data)) return
|
||||
|
||||
let m
|
||||
try {
|
||||
m = JSON.parse(data.toString())
|
||||
} catch {
|
||||
return // drop invalid JSON silently
|
||||
}
|
||||
if (!m || typeof m !== 'object' || typeof m.t !== 'string') return
|
||||
|
||||
// Rate-limiting check
|
||||
const now = Date.now()
|
||||
if (now > ws._actBucket.resetAt) {
|
||||
ws._actBucket.count = 0
|
||||
ws._actBucket.resetAt = now + 1000
|
||||
}
|
||||
ws._actBucket.count++
|
||||
if (ws._actBucket.count > MAX_ACTIONS_PER_SEC) {
|
||||
send(ws, { t: 'error', msg: 'Zu viele Anfragen gesendet.' })
|
||||
return
|
||||
}
|
||||
|
||||
switch (m.t) {
|
||||
case 'create': {
|
||||
leaveRoom(ws)
|
||||
if (m.mode !== 'coop' && m.mode !== 'duel') return
|
||||
const code = makeCode()
|
||||
const name = sanitizeName(m.name)
|
||||
const room = {
|
||||
code,
|
||||
mode: m.mode,
|
||||
created: Date.now(),
|
||||
seq: 0,
|
||||
players: [{ ws, id: 0, name }],
|
||||
}
|
||||
rooms.set(code, room)
|
||||
ws._room = code
|
||||
send(ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: 0 })
|
||||
break
|
||||
}
|
||||
case 'join': {
|
||||
leaveRoom(ws)
|
||||
const code = String(m.code || '').toUpperCase().trim()
|
||||
const room = rooms.get(code)
|
||||
if (!room) return send(ws, { t: 'error', msg: 'Raum nicht gefunden.' })
|
||||
if (room.players.length >= 2) return send(ws, { t: 'error', msg: 'Raum ist bereits voll.' })
|
||||
const name = sanitizeName(m.name)
|
||||
room.players.push({ ws, id: 1, name })
|
||||
ws._room = code
|
||||
for (const p of room.players) {
|
||||
send(p.ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: p.id })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'start': {
|
||||
const room = roomOf(ws)
|
||||
if (!room || room.players.length !== 2) return
|
||||
if (room.players[0].ws !== ws) return // only host may start
|
||||
const seed = (Math.random() * 1e9) | 0
|
||||
for (const p of room.players) {
|
||||
send(p.ws, { t: 'start', seed, mode: room.mode, players: playerInfo(room), you: p.id })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'act': {
|
||||
const room = roomOf(ws)
|
||||
if (!room || room.players.length < 2) return
|
||||
const from = room.players.findIndex((p) => p.ws === ws)
|
||||
if (from === -1) return
|
||||
if (!validateAction(m.a)) return
|
||||
if (!Number.isInteger(m.tick) || m.tick < 0) return
|
||||
|
||||
room.seq = (room.seq || 0) + 1
|
||||
for (const p of room.players) {
|
||||
send(p.ws, { t: 'act', seq: room.seq, from, tick: m.tick, a: m.a })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'prog': {
|
||||
const room = roomOf(ws)
|
||||
if (!room) return
|
||||
const from = room.players.findIndex((p) => p.ws === ws)
|
||||
if (from === -1) return
|
||||
if (typeof m.tick !== 'number') return
|
||||
broadcast(room, { t: 'prog', from, tick: m.tick })
|
||||
break
|
||||
}
|
||||
case 'leave': {
|
||||
leaveRoom(ws)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => leaveRoom(ws))
|
||||
ws.on('error', () => leaveRoom(ws))
|
||||
})
|
||||
|
||||
wss.on('listening', () => {
|
||||
console.log(`TRXTD Multiplayer-Server läuft auf ws://localhost:${PORT}`)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue