diff --git a/scripts/test-map-room.mjs b/scripts/test-map-room.mjs new file mode 100644 index 0000000..c25a1e9 --- /dev/null +++ b/scripts/test-map-room.mjs @@ -0,0 +1,70 @@ +/** + * Smoke test for the map-aware multiplayer room protocol: + * create with a map, host changes the map, join sees the map, start carries it. + * Requires the server: PORT=3102 node server/server.mjs + */ +import { WebSocket } from 'ws' + +const PORT = 3102 +const URL = `ws://localhost:${PORT}` + +function connect(origin) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(URL, origin ? { origin } : undefined) + const queue = [] + const waiters = [] + ws.on('open', () => resolve({ + ws, + next: () => new Promise((res) => { + const m = queue.shift() + if (m) return res(m) + waiters.push(res) + }), + close: () => ws.close(), + })) + ws.on('message', (d) => { + const m = JSON.parse(d.toString()) + const w = waiters.shift() + if (w) w(m) + else queue.push(m) + }) + ws.on('error', reject) + setTimeout(() => reject(new Error('timeout')), 4000) + }) +} + +const host = await connect('http://localhost:' + PORT) +host.ws.send(JSON.stringify({ t: 'create', mode: 'duel', name: 'Host', mapId: 'volcano' })) +const room = await host.next() +console.log('1) Raum erstellt mit Karte :', room.t === 'room' && room.mapId === 'volcano' ? 'OK (volcano)' : `FEHLER (${JSON.stringify(room)})`) + +const guest = await connect('http://localhost:' + PORT) +guest.ws.send(JSON.stringify({ t: 'join', code: room.code, name: 'Gast' })) +const join = await guest.next() +const joinHost = await host.next() +console.log('2) Gast sieht Karte :', join.t === 'room' && join.mapId === 'volcano' ? 'OK' : `FEHLER (${JSON.stringify(join)})`) +console.log('3) Host-Sync bleibt Karte :', joinHost.t === 'room' && joinHost.mapId === 'volcano' ? 'OK' : `FEHLER (${JSON.stringify(joinHost)})`) + +// guest may NOT change the map +guest.ws.send(JSON.stringify({ t: 'set-map', mapId: 'desert' })) +await new Promise((r) => setTimeout(r, 300)) +console.log('4) Gast darf Karte nicht ändern: OK (Server ignoriert)') + +host.ws.send(JSON.stringify({ t: 'set-map', mapId: 'frostland' })) +const m1 = await host.next() +const m2 = await guest.next() +console.log('5) Host wechselt Karte :', m1.mapId === 'frostland' && m2.mapId === 'frostland' ? 'OK (frostland)' : `FEHLER (${m1.mapId}/${m2.mapId})`) + +host.ws.send(JSON.stringify({ t: 'start' })) +const s1 = await host.next() +const s2 = await guest.next() +console.log('6) Start trägt Karte :', s1.t === 'start' && s1.mapId === 'frostland' && s2.mapId === 'frostland' ? 'OK' : `FEHLER (${JSON.stringify([s1, s2])})`) + +// invalid map id must fall back to meadow +const host2 = await connect('http://localhost:' + PORT) +host2.ws.send(JSON.stringify({ t: 'create', mode: 'coop', name: 'H2', mapId: 'hack' })) +const r2 = await host2.next() +console.log('7) Ungültige Karte -> Wiese :', r2.mapId === 'meadow' ? 'OK' : `FEHLER (${r2.mapId})`) + +host.close(); guest.close(); host2.close() +process.exit(0) diff --git a/scripts/test-maps.mts b/scripts/test-maps.mts new file mode 100644 index 0000000..b868028 --- /dev/null +++ b/scripts/test-maps.mts @@ -0,0 +1,165 @@ +/** + * Map system verification: + * - every map builds a valid, non-empty path & fly path + * - path cells never collide with blocked (obstacle) cells + * - maps differ from each other (distinct layouts) + * - lockstep determinism holds on every map (two engines, same actions) + * - obstacle clearing frees build tiles on every map + * - a full 20-wave simulation is beatable/playable on every map + */ +import { GameEngine } from '../src/game/engine.ts' +import { MAPS, MAP_ORDER } from '../src/game/config.ts' +import type { MapId } from '../src/game/types.ts' + +let failures = 0 +function check(label: string, ok: boolean, detail = ''): void { + if (!ok) { + failures++ + console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`) + } else { + console.log(` ✓ ${label}`) + } +} + +for (const mapId of MAP_ORDER) { + const map = MAPS[mapId] + console.log(`\n=== ${map.name} (${mapId}) ===`) + + const eng = new GameEngine() + eng.setMap(mapId) + eng.startGame('normal', mapId) + + // 1. path built & sane + check('Wegpunkte vorhanden', eng.pathPx.length >= 2) + check('Flugweg vorhanden', eng.flyPathPx.length >= 2) + check('Pfadzellen markiert', eng.pathCells.size > 0) + + const wp = map.waypoints + const interior = wp.slice(1, -1) + check( + 'Innere Wegpunkte im Spielfeld', + interior.every(([x, y]) => x >= 0 && x < 20 && y >= 0 && y < 11), + JSON.stringify(wp), + ) + check( + 'Start/Ende am Spielfeldrand', + wp.every(([x, y]) => x === -1 || x === 20 || y >= 0), + ) + + // 2. no collision between path and obstacles + const overlap = [...eng.pathCells].filter((k) => eng.blockedCells.has(k)) + check('Keine Hindernisse auf dem Weg', overlap.length === 0, overlap.join(',')) + + // 3. obstacles exist and are removable + const obstacleCells = [...eng.blockedCells] + check('Hindernisse vorhanden', obstacleCells.length >= 8, `${obstacleCells.length} Zellen`) + const [otx, oty] = obstacleCells[0].split(',').map(Number) + eng.money = 999999 + eng.selectedObstacle = { tx: otx, ty: oty } + eng.removeSelectedObstacle() + check('Hindernis entfernbar', !eng.blockedCells.has(`${otx},${oty}`) && eng.canPlace(otx, oty)) + + // 4. can place a tower next to the path + const candidates: [number, number][] = [] + for (let tx = 0; tx < 20 && candidates.length < 1; tx++) { + for (let ty = 0; ty < 11; ty++) { + if (eng.canPlace(tx, ty)) { + candidates.push([tx, ty]) + break + } + } + } + check('Freier Bauplatz gefunden', candidates.length > 0) +} + +// 5. maps are distinct from each other +console.log('\n=== Karten unterscheiden sich ===') +const signatures = MAP_ORDER.map((m) => { + const e = new GameEngine() + e.setMap(m) + return [m, [...e.pathCells].sort().join('|')] as const +}) +for (let i = 0; i < signatures.length; i++) { + for (let j = i + 1; j < signatures.length; j++) { + check( + `${signatures[i][0]} ≠ ${signatures[j][0]}`, + signatures[i][1] !== signatures[j][1], + ) + } +} + +// 6. lockstep determinism per map (same action stream on two engines) +console.log('\n=== Lockstep-Determinismus je Karte ===') +for (const mapId of MAP_ORDER) { + const e1 = new GameEngine() + const e2 = new GameEngine() + for (const e of [e1, e2]) { + e.startGame('normal', mapId) + e.mpGameActive = true + e.mpMode = 'coop' + e.coopHpMul = 1.7 + } + + const script: [number, Parameters[0]][] = [ + [5, { type: 'build', kind: 'arrow', tx: 8, ty: 5 }], + [10, { type: 'wave' }], + [200, { type: 'build', kind: 'frost', tx: 4, ty: 0 }], + [700, { type: 'wave' }], + [900, { type: 'obstacle', tx: 19, ty: 0 }], + [1500, { type: 'speed', s: 2 }], + ] + + let idx = 0 + for (let tick = 1; tick <= 2400; tick++) { + while (idx < script.length && script[idx][0] <= tick) { + const a = script[idx][1] + e1.applyAction(a, idx % 2) + e2.applyAction(a, idx % 2) + idx++ + } + e1.update(1 / 30) + e2.update(1 / 30) + } + + const state = (e: GameEngine): string => + JSON.stringify({ + money: Math.floor(e.money * 1000), + lives: e.lives, + wave: e.waveNo, + phase: e.phase, + enemies: e.enemies.map((x) => [x.id, Math.round(x.hp * 1000), x.wp]), + towers: e.towers.map((t) => [t.id, t.kind, t.level]), + blocked: e.blockedCells.size, + pathLen: e.pathPx.length, + }) + check(`${mapId}: identischer Zustand`, state(e1) === state(e2)) +} + +// 7. full campaign simulation on every map (playable check) +console.log('\n=== 20-Wellen-Simulation je Karte ===') +for (const mapId of MAP_ORDER) { + const e = new GameEngine() + e.startGame('easy', mapId) + // generous money + a tower on every free tile to verify the map is beatable + e.money = 999999 + for (let tx = 0; tx < 20; tx++) { + for (let ty = 0; ty < 11; ty++) { + if (e.canPlace(tx, ty)) e.placeTower('laser', tx, ty) + } + } + let finished = false + for (let t = 0; t < 60000 && !finished; t++) { + e.update(1 / 30) + if (e.phase === 'intermission') { + e.startWave(true) + } + finished = e.phase === 'victory' || e.phase === 'gameover' + } + check(`${mapId}: Kampagne abgeschlossen`, e.phase === 'victory', `Phase: ${e.phase}, Welle ${e.waveNo}`) +} + +if (failures > 0) { + console.error(`\n${failures} Prüfung(en) fehlgeschlagen.`) + process.exit(1) +} +console.log('\nAlle Karten-Prüfungen bestanden. ✅') diff --git a/server/server.mjs b/server/server.mjs index d4d76b6..aa5625d 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -235,12 +235,18 @@ function makeCode() { return code } +const VALID_MAPS = ['meadow', 'desert', 'frostland', 'volcano'] + function sanitizeName(raw) { if (typeof raw !== 'string') return 'Spieler' const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12) return clean || 'Spieler' } +function sanitizeMapId(raw) { + return VALID_MAPS.includes(raw) ? raw : 'meadow' +} + function send(ws, obj) { if (ws && ws.readyState === 1) { try { @@ -384,10 +390,21 @@ wss.on('connection', (ws) => { return send(ws, { t: 'error', msg: 'Zu viele aktive Räume. Bitte später erneut versuchen.' }) } const code = makeCode() - const room = { code, mode: m.mode, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] } + const mapId = sanitizeMapId(m.mapId) + const room = { code, mode: m.mode, mapId, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] } rooms.set(code, room) ws._room = code - send(ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: 0 }) + send(ws, { t: 'room', code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: 0 }) + break + } + case 'set-map': { + const room = roomOf(ws) + if (!room || room.players.length === 0) return + if (room.players[0].ws !== ws) return // only host may change map + room.mapId = sanitizeMapId(m.mapId) + for (const p of room.players) { + send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id }) + } break } case 'join': { @@ -399,7 +416,7 @@ wss.on('connection', (ws) => { room.players.push({ ws, id: 1, name: sanitizeName(m.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 }) + send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id }) } break } @@ -409,7 +426,7 @@ wss.on('connection', (ws) => { if (room.players[0].ws !== ws) return // only the 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 }) + send(p.ws, { t: 'start', seed, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id }) } break } diff --git a/src/components/EndOverlay.vue b/src/components/EndOverlay.vue index 7638210..4aa3985 100644 --- a/src/components/EndOverlay.vue +++ b/src/components/EndOverlay.vue @@ -4,7 +4,7 @@ import { engine } from '@/game/engine' import { mpgame } from '@/game/mpgame' function restart(): void { - engine.startGame(store.difficulty) + engine.startGame(store.difficulty, store.mapId) } function endless(): void { diff --git a/src/components/LobbyScreen.vue b/src/components/LobbyScreen.vue index 332f616..f1b0f97 100644 --- a/src/components/LobbyScreen.vue +++ b/src/components/LobbyScreen.vue @@ -1,7 +1,9 @@