ENDLESS GOLF

8 Themes · Infinite Courses
HOLE
1
PAR
4
STROKES
0
TOTAL
E
Forest
0%
WebGL bu cihazda desteklenmiyor.
Lütfen modern bir tarayıcı deneyin.

🎨 CUSTOMIZE

Ball Color
Pattern
Face
Hat
Hat Color
A TURN relay is required for players behind symmetric NATs or restrictive corporate firewalls. STUN alone is not enough there. ============================================================ */ const NET = { PREFIX: 'endlessgolf-v1-', CODE_ALPHABET: '0123456789ABCDEFGHJKMNPQRSTVWXYZ', // Crockford-ish: no I, L, O, U CODE_LEN: 5, MAX_PLAYERS: 6, MIN_PLAYERS: 2, VERSION: 1, JOIN_TIMEOUT: 12000, RECONNECT_GRACE: 20000, BALL_RATE: 12, ICE: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ], PEERJS_CDN: 'https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js' }; function netConfig(){ return window.ENDLESS_GOLF_CONFIG || {}; } function randomBytes(n){ const b = new Uint8Array(n); if(window.crypto && window.crypto.getRandomValues) window.crypto.getRandomValues(b); else for(let i=0;i>> 0); } function randomToken(){ const b = randomBytes(9); let s = ''; for(let i=0;i]/g, '').trim().slice(0, 14); } function sanitizeCustom(c){ const out = { ballColor: '#ffffff' }; if(c && typeof c === 'object' && typeof c.ballColor === 'string' && /^#[0-9a-fA-F]{6}$/.test(c.ballColor)){ out.ballColor = c.ballColor.toLowerCase(); } return out; } function safeMsg(data){ if(typeof data !== 'object' || data === null || Array.isArray(data)) return null; if(typeof data.t !== 'string' || data.t.length === 0 || data.t.length > 16) return null; return data; } function rateOk(conn, type){ const now = Date.now(); let r = conn._rl; if(!r || now - r.t0 > 1000){ r = conn._rl = { t0: now, n: 0 }; } r.n++; const limit = (type === 'ball') ? NET.BALL_RATE * 3 : 24; return r.n <= limit; } function joinErrorText(reason){ switch(reason){ case 'full': return 'Oda dolu.'; case 'started': return 'Maç çoktan başladı.'; case 'version': return 'Sürüm uyumsuz. Sayfayı yenileyin.'; case 'bad-token': return 'Geçersiz istemci kimliği.'; default: return 'Odaya katılınamadı.'; } } /* ---- Pure lobby model (unit-testable, no networking) ---- */ function createLobbyState(code, hostToken, hostName, hostCustom){ const lobby = { code, hostToken, players: new Map(), started: false, maxPlayers: NET.MAX_PLAYERS, minPlayers: NET.MIN_PLAYERS, version: NET.VERSION }; lobby.players.set(hostToken, { token: hostToken, name: sanitizeName(hostName) || 'Host', ready: true, host: true, connected: true, strokes: 0, holed: false, custom: sanitizeCustom(hostCustom), lastSeen: Date.now(), joinedAt: Date.now(), lastStrokes: undefined, lastPos: null }); return lobby; } function lobbyAddPlayer(lobby, p){ if(!lobby || !p || typeof p.token !== 'string' || !p.token) return { ok:false, reason:'bad-token' }; const existing = lobby.players.get(p.token); if(existing){ existing.connected = true; existing.lastSeen = Date.now(); const nm = sanitizeName(p.name); if(nm) existing.name = nm; if(p.custom) existing.custom = sanitizeCustom(p.custom); return { ok:true, rejoin:true }; } if(lobby.players.size >= lobby.maxPlayers) return { ok:false, reason:'full' }; if(lobby.started) return { ok:false, reason:'started' }; lobby.players.set(p.token, { token: p.token, name: sanitizeName(p.name) || ('Player ' + (lobby.players.size + 1)), ready: false, host: false, connected: true, strokes: 0, holed: false, custom: sanitizeCustom(p.custom), lastSeen: Date.now(), joinedAt: Date.now(), lastStrokes: undefined, lastPos: null }); return { ok:true, rejoin:false }; } function lobbyRemovePlayer(lobby, token){ if(!lobby) return false; if(token === lobby.hostToken) return false; return lobby.players.delete(token); } function lobbySetReady(lobby, token, ready){ if(!lobby) return false; const p = lobby.players.get(token); if(!p) return false; p.ready = !!ready; return true; } function lobbyCanStart(lobby, token){ if(!lobby) return { ok:false, reason:'no-lobby' }; if(token !== lobby.hostToken) return { ok:false, reason:'not-host' }; if(lobby.started) return { ok:false, reason:'already-started' }; const connected = [...lobby.players.values()].filter(p => p.connected); if(connected.length < lobby.minPlayers) return { ok:false, reason:'need-players' }; for(const p of connected){ if(p.host) continue; if(!p.ready) return { ok:false, reason:'not-ready' }; } return { ok:true }; } function lobbyPrune(lobby, now, grace){ const removed = []; if(!lobby) return removed; for(const [tok, p] of [...lobby.players]){ if(p.connected) continue; if(tok === lobby.hostToken) continue; if(now - (p.lastSeen || 0) > grace){ lobby.players.delete(tok); removed.push(tok); } } return removed; } function lobbySnapshot(lobby){ if(!lobby) return []; return [...lobby.players.values()].map(p => ({ id: p.token, name: p.name, ready: !!p.ready, host: !!p.host, connected: !!p.connected, strokes: p.strokes|0, color: (p.custom && p.custom.ballColor) || '#ffffff' })); } function validateBallMsg(msg, player, hole){ if(!hole) return false; const x = +msg.x, y = +msg.y, z = +msg.z; if(!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false; const b = hole.bounds; const M = 30; if(x < b.minX-M || x > b.maxX+M || y < b.minY-M || y > b.maxY+M) return false; if(z < -60 || z > 600) return false; const s = msg.s|0; if(s < 0 || s > 500) return false; if(player.lastStrokes !== undefined){ if(s < player.lastStrokes) return false; if(s > player.lastStrokes + 3) return false; } if(player.lastPos){ const d = Math.hypot(x - player.lastPos.x, y - player.lastPos.y); if(d > 90) return false; } player.lastStrokes = s; player.lastPos = { x, y }; return true; } /* ---- Remote ball rendering ---- */ const remoteBalls = new Map(); // token -> { group, mesh, target, cur, strokes } function clearRemoteBalls(){ for(const [,rb] of remoteBalls){ try{ scene.remove(rb.group); rb.mesh.geometry.dispose(); rb.mesh.material.dispose(); if(rb.shadow){ rb.shadow.geometry === SHADOW_GEO; rb.shadow.material.dispose(); } }catch(e){} } remoteBalls.clear(); } function ensureRemoteBall(token, colorHex){ let rb = remoteBalls.get(token); if(rb) return rb; if(!scene) return null; const group = new THREE.Group(); let col = 0xffffff; try{ col = new THREE.Color(colorHex || '#ffffff').getHex(); }catch(e){} const mesh = new THREE.Mesh( new THREE.SphereGeometry(BALL_R, 16, 12), new THREE.MeshLambertMaterial({ color: col }) ); group.add(mesh); const shadow = new THREE.Mesh(SHADOW_GEO, new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.32, depthWrite: false })); shadow.scale.setScalar(BALL_R * 1.9); group.add(shadow); scene.add(group); rb = { group, mesh, shadow, target: { x:0, y:0, z:BALL_R }, cur: { x:0, y:0, z:BALL_R }, initialized: false, strokes: 0, color: col }; remoteBalls.set(token, rb); return rb; } function updateRemoteBall(token, x, y, z, strokes){ const entry = Net.roster.find(p => p.id === token); const color = entry ? entry.color : '#ffffff'; const rb = ensureRemoteBall(token, color); if(!rb) return; if(!rb.initialized){ rb.cur.x = x; rb.cur.y = y; rb.cur.z = z; rb.initialized = true; } rb.target.x = x; rb.target.y = y; rb.target.z = z; rb.strokes = strokes|0; if(entry) entry.strokes = strokes|0; } function removeRemoteBall(token){ const rb = remoteBalls.get(token); if(!rb) return; try{ scene.remove(rb.group); rb.mesh.geometry.dispose(); rb.mesh.material.dispose(); rb.shadow.material.dispose(); }catch(e){} remoteBalls.delete(token); } function updateRemoteBalls(dt){ if(remoteBalls.size === 0) return; const k = 1 - Math.pow(0.0001, Math.min(dt, 0.1)); for(const [,rb] of remoteBalls){ rb.cur.x = lerp(rb.cur.x, rb.target.x, k); rb.cur.y = lerp(rb.cur.y, rb.target.y, k); rb.cur.z = lerp(rb.cur.z, rb.target.z, k); rb.group.position.set(rb.cur.x, rb.cur.z, rb.cur.y); const gy = HOLE ? groundHeight(rb.cur.x, rb.cur.y) : 0; rb.shadow.position.set(0, gy - rb.cur.z + 0.09, 0); const h = clamp(rb.cur.z - BALL_R - gy, 0, 40); rb.shadow.scale.setScalar(BALL_R * 1.9 * clamp(1 - h*0.04, 0.4, 1)); rb.shadow.material.opacity = 0.32 * clamp(1 - h*0.04, 0.4, 1); } } /* ---- PeerJS loader ---- */ let peerJsPromise = null; function loadPeerJS(){ if(peerJsPromise) return peerJsPromise; peerJsPromise = new Promise((resolve, reject) => { if(window.Peer) { resolve(window.Peer); return; } const cfg = netConfig(); const src = cfg.peerJsUrl || NET.PEERJS_CDN; const s = document.createElement('script'); s.src = src; s.async = true; s.onload = () => window.Peer ? resolve(window.Peer) : reject(new Error('PeerJS yüklenemedi.')); s.onerror = () => reject(new Error('PeerJS indirilemedi (ağ hatası).')); document.head.appendChild(s); }); return peerJsPromise; } function peerOptions(){ const cfg = netConfig(); const ice = (Array.isArray(cfg.iceServers) && cfg.iceServers.length) ? cfg.iceServers : NET.ICE; const opts = { debug: 0, config: { iceServers: ice } }; if(cfg.signaling && typeof cfg.signaling === 'object'){ Object.keys(cfg.signaling).forEach(k => { opts[k] = cfg.signaling[k]; }); } return opts; } /* ---- Network manager ---- */ const Net = { mode: 'offline', // 'offline' | 'host' | 'client' code: null, peer: null, peerId: null, token: null, hostPeerId: null, hostConn: null, conns: new Map(), // host: token -> DataConnection connToken: new Map(), // host: conn.peer -> token lobby: null, // host authoritative lobby state roster: [], // both sides: player DTO list status: 'idle', statusMsg: '', holeSeed: 0, inGame: false, onStatus: null, onLobby: null, onStart: null, onError: null, _joinResolve: null, _joinReject: null, _joinTimer: null, _pruneTimer: null, /* ---------- helpers ---------- */ emitStatus(s, m){ this.status = s; this.statusMsg = m || ''; if(this.onStatus) this.onStatus(s, this.statusMsg); }, send(conn, msg){ if(!conn) return false; try{ if(conn.open === false) return false; conn.send(msg); return true; }catch(e){ return false; } }, broadcast(msg, except){ if(this.mode !== 'host') return; for(const [,c] of this.conns){ if(c === except) continue; this.send(c, msg); } }, _broadcastLobby(){ if(this.mode !== 'host' || !this.lobby) return; this.roster = lobbySnapshot(this.lobby); this.broadcast({ t:'lobby', players: this.roster, started: !!this.lobby.started }); if(this.onLobby) this.onLobby(this.roster); }, allHoled(){ if(!this.lobby) return true; for(const p of this.lobby.players.values()){ if(p.host) continue; if(!p.connected) continue; if(!p.holed) return false; } return true; }, resetProgress(){ if(!this.lobby) return; for(const p of this.lobby.players.values()){ p.strokes = 0; p.holed = false; p.lastStrokes = undefined; p.lastPos = null; } }, isHost(){ return this.mode === 'host'; }, isOnline(){ return this.mode !== 'offline'; }, /* ---------- host: create ---------- */ async createRoom(name, custom){ if(this.mode !== 'offline') throw new Error('Zaten bir odadasınız.'); const Peer = await loadPeerJS(); this.mode = 'host'; this.emitStatus('connecting', 'Oda oluşturuluyor…'); this.token = randomToken(); this.holeSeed = randomSeed(); let lastErr = null; for(let attempt = 0; attempt < 5; attempt++){ const code = genCode(); let peer; try{ peer = await this._openPeer(Peer, NET.PREFIX + code); }catch(err){ lastErr = err; if(err && err.__type === 'unavailable-id') continue; // collision — retry this.mode = 'offline'; this.emitStatus('error', 'Sinyal sunucusuna bağlanılamadı.'); throw new Error('Sinyal sunucusuna bağlanılamadı.'); } this.peer = peer; this.code = code; this.peerId = peer.id; this.hostPeerId = peer.id; this.lobby = createLobbyState(code, this.token, name, custom); this._wireHost(peer); this.roster = lobbySnapshot(this.lobby); this.emitStatus('connected', ''); if(this.onLobby) this.onLobby(this.roster); this._startPruneTimer(); return code; } this.mode = 'offline'; throw lastErr || new Error('Oda kodu üretilemedi.'); }, _openPeer(Peer, id){ return new Promise((resolve, reject) => { let peer; try{ peer = id ? new Peer(id, peerOptions()) : new Peer(peerOptions()); }catch(e){ if(e && e.type) e.__type = e.type; reject(e); return; } let done = false; const to = setTimeout(() => { if(done) return; done = true; try{ peer.destroy(); }catch(e){} const err = new Error('Bağlantı zaman aşımına uğradı.'); err.__type = 'timeout'; reject(err); }, NET.JOIN_TIMEOUT); peer.on('open', () => { if(done) return; done = true; clearTimeout(to); resolve(peer); }); peer.on('error', (err) => { if(done){ Net._onPeerError(err); return; } done = true; clearTimeout(to); try{ peer.destroy(); }catch(e){} if(err && !err.__type && err.type) err.__type = err.type; reject(err); }); }); }, _wireHost(peer){ peer.on('connection', conn => this._hostAccept(conn)); peer.on('disconnected', () => { try{ peer.reconnect(); }catch(e){} }); }, _hostAccept(conn){ conn._token = null; conn._helloTimeout = setTimeout(() => { if(!conn._token){ try{ conn.close(); }catch(e){} } }, NET.JOIN_TIMEOUT); conn.on('data', d => this._hostOnData(conn, d)); conn.on('close', () => this._hostOnClose(conn)); conn.on('error', () => this._hostOnClose(conn)); }, _hostOnData(conn, data){ const msg = safeMsg(data); if(!msg) return; // malformed → ignore if(!rateOk(conn, msg.t)) return; // rate limited if(msg.t === 'hello'){ this._hostHello(conn, msg); return; } const token = conn._token; if(!token) return; // must hello first if(!this.lobby) return; const p = this.lobby.players.get(token); if(!p) return; // unknown origin → ignore switch(msg.t){ case 'ready': p.ready = !!msg.ready; p.lastSeen = Date.now(); this._broadcastLobby(); break; case 'ball': if(!this.lobby.started) return; if(!validateBallMsg(msg, p, HOLE)) return; p.strokes = msg.s|0; p.holed = !!msg.h; p.lastSeen = Date.now(); this.broadcast({ t:'ball', id: token, x: +msg.x, y: +msg.y, z: +msg.z, s: p.strokes, h: p.holed ? 1 : 0 }, conn); updateRemoteBall(token, +msg.x, +msg.y, +msg.z, p.strokes); updateMpPanel(); break; case 'leave': case 'bye': this._hostDropPlayer(token); try{ conn.close(); }catch(e){} break; case 'ping': this.send(conn, { t:'pong', ts: msg.ts }); break; default: break; // unknown types ignored } }, _hostHello(conn, msg){ if(this.mode !== 'host' || !this.lobby){ this.send(conn, { t:'reject', reason:'closed' }); return; } if(msg.v !== NET.VERSION){ this.send(conn, { t:'reject', reason:'version' }); setTimeout(() => { try{ conn.close(); }catch(e){} }, 300); return; } const tok = (typeof msg.token === 'string') ? msg.token.slice(0, 40) : ''; if(!tok){ this.send(conn, { t:'reject', reason:'bad-token' }); setTimeout(() => { try{ conn.close(); }catch(e){} }, 300); return; } const res = lobbyAddPlayer(this.lobby, { token: tok, name: msg.name, custom: msg.custom }); if(!res.ok){ this.send(conn, { t:'reject', reason: res.reason }); setTimeout(() => { try{ conn.close(); }catch(e){} }, 300); return; } const prev = this.conns.get(tok); if(prev && prev !== conn){ try{ prev.close(); }catch(e){} } this.conns.set(tok, conn); this.connToken.set(conn.peer, tok); conn._token = tok; clearTimeout(conn._helloTimeout); this.send(conn, { t:'welcome', v: NET.VERSION, code: this.code, you: tok, host: this.token, players: lobbySnapshot(this.lobby), hole: G.hole, seed: this.holeSeed, inGame: !!this.lobby.started }); this._broadcastLobby(); }, _hostDropPlayer(token){ if(!this.lobby || !token) return; const conn = this.conns.get(token); this.conns.delete(token); if(conn) this.connToken.delete(conn.peer); lobbyRemovePlayer(this.lobby, token); removeRemoteBall(token); this._broadcastLobby(); updateMpPanel(); }, _hostOnClose(conn){ const tok = conn._token || this.connToken.get(conn.peer); if(!tok) return; if(this.conns.get(tok) !== conn) return; // stale connection this.conns.delete(tok); const p = this.lobby && this.lobby.players.get(tok); if(p){ p.connected = false; p.ready = false; p.lastSeen = Date.now(); } removeRemoteBall(tok); this._broadcastLobby(); updateMpPanel(); }, _startPruneTimer(){ this._stopPruneTimer(); this._pruneTimer = setInterval(() => { if(this.mode !== 'host' || !this.lobby) return; const removed = lobbyPrune(this.lobby, Date.now(), NET.RECONNECT_GRACE); if(removed.length){ for(const tok of removed){ const c = this.conns.get(tok); if(c){ try{ c.close(); }catch(e){} this.conns.delete(tok); } removeRemoteBall(tok); } this._broadcastLobby(); updateMpPanel(); } }, 2000); }, _stopPruneTimer(){ if(this._pruneTimer){ clearInterval(this._pruneTimer); this._pruneTimer = null; } }, /* ---------- client: join ---------- */ async joinRoom(rawCode, name, custom){ if(this.mode !== 'offline') throw new Error('Zaten bir odadasınız.'); const code = normalizeCode(rawCode); if(!isValidCode(code)) throw new Error('Geçersiz oda kodu. 5 karakter olmalı.'); const Peer = await loadPeerJS(); this.mode = 'client'; this.code = code; this.token = randomToken(); this.emitStatus('connecting', 'Odaya bağlanılıyor…'); let peer; try{ peer = await this._openPeer(Peer, null); }catch(e){ this.mode = 'offline'; this.emitStatus('error', 'Sinyal sunucusuna bağlanılamadı.'); throw new Error('Sinyal sunucusuna bağlanılamadı.'); } this.peer = peer; this.peerId = peer.id; this.hostPeerId = NET.PREFIX + code; return new Promise((resolve, reject) => { this._joinResolve = resolve; this._joinReject = reject; this._joinTimer = setTimeout(() => { const rej = this._joinReject; this._clearJoin(); try{ if(this.hostConn) this.hostConn.close(); }catch(e){} if(rej) rej(new Error('Bağlantı zaman aşımına uğradı.')); }, NET.JOIN_TIMEOUT); let conn; try{ conn = peer.connect(this.hostPeerId, { reliable: true }); }catch(e){ this._failJoin('Bağlantı kurulamadı.'); return; } this.hostConn = conn; conn.on('open', () => { this.send(conn, { t:'hello', v: NET.VERSION, token: this.token, name: sanitizeName(name), custom: sanitizeCustom(custom) }); }); conn.on('data', d => this._clientOnData(conn, d)); conn.on('close', () => this._clientOnClose(conn)); conn.on('error', () => this._clientOnClose(conn)); }); }, _clientOnData(conn, data){ if(conn !== this.hostConn) return; const msg = safeMsg(data); if(!msg) return; if(!rateOk(conn, msg.t)) return; switch(msg.t){ case 'welcome': { const r = this._joinResolve; this._clearJoin(); this.roster = Array.isArray(msg.players) ? msg.players : []; this.inGame = !!msg.inGame; this.holeSeed = msg.seed|0; G.hole = (msg.hole|0) || 1; this.emitStatus('connected', ''); if(this.onLobby) this.onLobby(this.roster); if(r) r(msg); break; } case 'reject': { const reason = msg.reason || 'reject'; const rej = this._joinReject; this._clearJoin(); this.emitStatus('error', joinErrorText(reason)); if(rej){ const e = new Error(joinErrorText(reason)); e.reason = reason; rej(e); } break; } case 'lobby': this.roster = Array.isArray(msg.players) ? msg.players : []; if(this.onLobby) this.onLobby(this.roster); break; case 'start': this.inGame = true; this.holeSeed = msg.seed|0; G.hole = (msg.hole|0) || 1; if(this.onStart) this.onStart(msg); break; case 'hole': this.holeSeed = msg.seed|0; applyHoleFromHost((msg.hole|0) || 1, this.holeSeed); break; case 'ball': if(typeof msg.id !== 'string') return; if(msg.id === this.token) return; updateRemoteBall(msg.id, +msg.x, +msg.y, +msg.z, msg.s|0); updateMpPanel(); break; case 'closed': this._clientHostGone(msg.reason || 'host-left'); break; case 'pong': default: break; } }, _clientOnClose(conn){ if(conn !== this.hostConn) return; if(this.status === 'closed') return; this._failJoin('Host bağlantısı kapandı.'); this._clientHostGone('host-left'); }, _clientHostGone(reason){ if(this.status === 'closed') return; const wasInGame = this.inGame; this.emitStatus('closed', reason); const cb = this.onError; this.cleanupLocal(); if(cb) cb(reason, wasInGame); }, _failJoin(text){ const rej = this._joinReject; this._clearJoin(); if(rej) rej(new Error(text)); }, _clearJoin(){ if(this._joinTimer){ clearTimeout(this._joinTimer); this._joinTimer = null; } this._joinResolve = null; this._joinReject = null; }, _onPeerError(err){ const type = err && err.type; if(type === 'peer-unavailable'){ this._failJoin('Bu kodla açık bir oda bulunamadı.'); return; } if(type === 'browser-incompatible'){ this._failJoin('Tarayıcı WebRTC desteklemiyor.'); return; } if(type === 'network' || type === 'server-error' || type === 'socket-error'){ if(this._joinReject) this._failJoin('Sinyal sunucusuna ulaşılamadı.'); else if(this.mode !== 'offline' && this.onError) this.onError('network', this.inGame); return; } }, /* ---------- teardown ---------- */ cleanupLocal(){ this._clearJoin(); this._stopPruneTimer(); for(const [,c] of this.conns){ try{ c.close(); }catch(e){} } this.conns.clear(); this.connToken.clear(); if(this.hostConn){ try{ this.hostConn.close(); }catch(e){} this.hostConn = null; } if(this.peer){ try{ this.peer.destroy(); }catch(e){} this.peer = null; } this.mode = 'offline'; this.code = null; this.peerId = null; this.hostPeerId = null; this.lobby = null; this.roster = []; this.inGame = false; this.status = 'idle'; this.statusMsg = ''; clearRemoteBalls(); }, leave(){ if(this.mode === 'offline') return; if(this.mode === 'client' && this.hostConn && this.hostConn.open){ this.send(this.hostConn, { t:'leave' }); } if(this.mode === 'host' && this.conns.size){ this.broadcast({ t:'closed', reason: 'host-left' }); } this.cleanupLocal(); }, /* ---------- host actions ---------- */ hostStartMatch(){ if(this.mode !== 'host' || !this.lobby) return false; const chk = lobbyCanStart(this.lobby, this.token); if(!chk.ok) return false; this.lobby.started = true; this.holeSeed = randomSeed(); this.inGame = true; this.broadcast({ t:'start', hole: 1, seed: this.holeSeed }); this.resetProgress(); this._broadcastLobby(); return true; }, broadcastBall(x, y, z, strokes, holed){ const msg = { t:'ball', id: this.token, x: +x.toFixed(2), y: +y.toFixed(2), z: +z.toFixed(2), s: strokes|0, h: holed ? 1 : 0 }; this.broadcast(msg); }, sendBallToHost(x, y, z, strokes, holed){ if(!this.hostConn) return; this.send(this.hostConn, { t:'ball', x: +x.toFixed(2), y: +y.toFixed(2), z: +z.toFixed(2), s: strokes|0, h: holed ? 1 : 0 }); }, sendCustom(){ if(this.mode === 'client' && this.hostConn && this.hostConn.open){ // custom is exchanged at join; colour changes mid-lobby are cosmetic only } } }; /* ---- ball state broadcasting throttle ---- */ let netAccum = 0; let lastSentStrokes = -1; let lastSentMoving = false; function netSendBall(force){ if(Net.mode === 'offline' || !Net.inGame) return; const holed = (G.state === 'holed') ? 1 : 0; const msg = { t:'ball', x:+ball.x.toFixed(2), y:+ball.y.toFixed(2), z:+ball.z.toFixed(2), s:G.strokes|0, h:holed }; if(Net.mode === 'host') Net.broadcast(msg); else Net.sendBallToHost(ball.x, ball.y, ball.z, G.strokes, holed); lastSentStrokes = G.strokes; } function netTick(dt){ if(Net.mode === 'offline' || !Net.inGame) return; netAccum += dt; const interval = 1 / NET.BALL_RATE; if(netAccum < interval) return; netAccum = 0; netSendBall(false); } function netSendCustom(){ /* hook used by customizer; colour sync is join-time only */ } /* ---- host-driven hole application (client side) ---- */ function applyHoleFromHost(h, seed){ G.hole = h; resetHolePresentation(); loadHole(h, seed); hideBanner(); } /* ============================================================ MULTIPLAYER UI ============================================================ */ let pendingJoinCode = null; let onlineBusy = false; function setOnlineStatus(text, kind){ onlineStatusEl.className = 'mp-status' + (kind ? ' ' + kind : ''); if(kind === 'loading'){ onlineStatusEl.innerHTML = '' + text; } else { onlineStatusEl.textContent = text || ''; } } function loadName(){ let n = ''; try{ n = localStorage.getItem(LS_NAME_KEY) || ''; }catch(e){} if(!n) n = 'Player ' + (100 + Math.floor(Math.random()*900)); nameInput.value = n; } function saveName(){ try{ localStorage.setItem(LS_NAME_KEY, sanitizeName(nameInput.value)); }catch(e){} } function showMenuMain(){ menuMain.classList.remove('hidden'); menuOnline.classList.add('hidden'); } function showMenuOnline(){ menuMain.classList.add('hidden'); menuOnline.classList.remove('hidden'); setOnlineStatus(''); setTimeout(()=>{ try{ nameInput.focus(); }catch(e){} }, 60); } onlineBtn.addEventListener('click', (e)=>{ e.stopPropagation(); SFX.init(); SFX.resume(); SFX.ui(); loadName(); showMenuOnline(); }); onlineBack.addEventListener('click', (e)=>{ e.stopPropagation(); SFX.ui(); showMenuMain(); }); nameInput.addEventListener('change', saveName); nameInput.addEventListener('blur', saveName); codeInput.addEventListener('input', ()=>{ const pos = codeInput.selectionStart; const before = codeInput.value; const cleaned = before.toUpperCase().replace(/[^0-9A-Za-z]/g,'').slice(0, NET.CODE_LEN); if(cleaned !== before){ codeInput.value = cleaned; try{ codeInput.setSelectionRange(Math.min(pos, cleaned.length), Math.min(pos, cleaned.length)); }catch(e){} } }); codeInput.addEventListener('keydown', (e)=>{ if(e.key === 'Enter'){ e.preventDefault(); attemptJoin(); } }); createBtn.addEventListener('click', async (e)=>{ e.stopPropagation(); if(onlineBusy) return; onlineBusy = true; SFX.init(); SFX.resume(); saveName(); createBtn.disabled = true; joinBtn.disabled = true; setOnlineStatus('Oda oluşturuluyor…', 'loading'); try{ const name = sanitizeName(nameInput.value) || 'Host'; await Net.createRoom(name, CUSTOM); setOnlineStatus('Oda hazır.', 'ok'); openLobby(); }catch(err){ setOnlineStatus(err && err.message ? err.message : 'Oda oluşturulamadı.', 'err'); }finally{ onlineBusy = false; createBtn.disabled = false; joinBtn.disabled = false; } }); joinBtn.addEventListener('click', (e)=>{ e.stopPropagation(); attemptJoin(); }); async function attemptJoin(){ if(onlineBusy) return; const code = normalizeCode(codeInput.value); if(!isValidCode(code)){ setOnlineStatus('Geçerli bir 5 karakterlik kod girin.', 'err'); codeInput.focus(); return; } onlineBusy = true; SFX.init(); SFX.resume(); saveName(); createBtn.disabled = true; joinBtn.disabled = true; setOnlineStatus('Odaya bağlanılıyor…', 'loading'); try{ const name = sanitizeName(nameInput.value) || 'Player'; pendingJoinCode = code; await Net.joinRoom(code, name, CUSTOM); setOnlineStatus('Bağlandı.', 'ok'); openLobby(); }catch(err){ setOnlineStatus(err && err.message ? err.message : 'Odaya katılınamadı.', 'err'); Net.cleanupLocal(); }finally{ onlineBusy = false; createBtn.disabled = false; joinBtn.disabled = false; } } /* ---- Lobby screen ---- */ function openLobby(){ lobbyCodeEl.textContent = Net.code || '-----'; lobbyScreen.classList.remove('hidden'); setLobbyStatus(''); renderLobby(); } function closeLobby(){ lobbyScreen.classList.add('hidden'); } function setLobbyStatus(text, kind){ lobbyStatusEl.className = 'lobby-status' + (kind ? ' ' + kind : ''); lobbyStatusEl.textContent = text || ''; } function renderLobby(){ const roster = Net.roster || []; const me = Net.token; const isHost = Net.mode === 'host'; lobbyCodeEl.textContent = Net.code || '-----'; lobbyPlayersEl.innerHTML = ''; roster.forEach(p => { const row = document.createElement('div'); row.className = 'pl-row'; const dot = document.createElement('span'); dot.className = 'pl-dot'; dot.style.background = p.color || '#ffffff'; row.appendChild(dot); const nm = document.createElement('span'); nm.className = 'pl-name'; nm.textContent = p.name + (p.id === me ? ' (you)' : ''); row.appendChild(nm); if(!p.connected){ const t = document.createElement('span'); t.className = 'pl-tag off'; t.textContent = 'offline'; row.appendChild(t); } if(p.host){ const t = document.createElement('span'); t.className = 'pl-tag host'; t.textContent = 'host'; row.appendChild(t); } else { const t = document.createElement('span'); t.className = 'pl-tag ' + (p.ready ? 'ready' : 'notready'); t.textContent = p.ready ? 'ready' : 'not ready'; row.appendChild(t); } lobbyPlayersEl.appendChild(row); }); // Ready button const mine = roster.find(p => p.id === me); const myReady = mine ? !!mine.ready : false; if(Net.mode === 'host'){ readyBtn.style.display = 'none'; } else { readyBtn.style.display = ''; readyBtn.textContent = myReady ? 'NOT READY' : 'READY'; readyBtn.classList.toggle('active', myReady); readyBtn.setAttribute('aria-pressed', myReady ? 'true' : 'false'); } // Start button if(isHost && Net.lobby){ startMatchBtn.style.display = ''; const chk = lobbyCanStart(Net.lobby, Net.token); startMatchBtn.disabled = !chk.ok; if(!chk.ok){ if(chk.reason === 'need-players') startMatchBtn.title = 'En az 2 oyuncu gerekli'; else if(chk.reason === 'not-ready') startMatchBtn.title = 'Herkes hazır olmalı'; else startMatchBtn.title = ''; } else { startMatchBtn.title = ''; } } else { startMatchBtn.style.display = 'none'; } if(Net.mode === 'client' && Net.inGame){ setLobbyStatus('Maç başlıyor…', ''); } } Net.onLobby = function(roster){ if(lobbyScreen.classList.contains('hidden')) return; renderLobby(); }; Net.onStatus = function(status, msg){ if(status === 'connecting') setLobbyStatus(msg || 'Bağlanılıyor…'); else if(status === 'connected') setLobbyStatus(''); }; Net.onStart = function(msg){ closeLobby(); startGame({ hole: (msg.hole|0) || 1, seed: msg.seed|0 }); }; Net.onError = function(reason, wasInGame){ closeLobby(); if(wasInGame || gameStarted){ returnToMenu(reason === 'host-left' ? 'Host odayı kapattı.' : 'Bağlantı kesildi.'); } else if(reason === 'network'){ setOnlineStatus('Sinyal sunucusuna ulaşılamadı.', 'err'); showMenuOnline(); } }; readyBtn.addEventListener('click', (e)=>{ e.stopPropagation(); if(Net.mode !== 'client' || !Net.hostConn) return; const mine = Net.roster.find(p => p.id === Net.token); const next = !(mine && mine.ready); Net.send(Net.hostConn, { t:'ready', ready: next }); if(mine) mine.ready = next; renderLobby(); SFX.ui(); }); startMatchBtn.addEventListener('click', (e)=>{ e.stopPropagation(); if(Net.mode !== 'host') return; if(Net.hostStartMatch()){ closeLobby(); startGame({ hole: 1, seed: Net.holeSeed }); } else { setLobbyStatus('Başlatılamadı — oyuncular hazır olmalı.', 'err'); } }); copyCodeBtn.addEventListener('click', async (e)=>{ e.stopPropagation(); const ok = await copyText(Net.code || ''); copyCodeBtn.textContent = ok ? 'COPIED' : 'COPY FAILED'; setTimeout(()=>{ copyCodeBtn.textContent = 'COPY'; }, 1400); }); leaveLobbyBtn.addEventListener('click', (e)=>{ e.stopPropagation(); confirmLeaveRoom(); }); async function copyText(txt){ try{ if(navigator.clipboard && navigator.clipboard.writeText){ await navigator.clipboard.writeText(txt); return true; } }catch(e){} try{ const ta = document.createElement('textarea'); ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; }catch(e){ return false; } } function confirmLeaveRoom(){ if(Net.mode === 'offline') return; const inGame = gameStarted && Net.inGame; const msg = inGame ? 'Odadan ayrılmak istediğinize emin misiniz? Maç sizin için sona erecek.' : 'Odadan ayrılmak istediğinize emin misiniz?'; if(!window.confirm(msg)) return; Net.leave(); closeLobby(); if(gameStarted) returnToMenu('Odadan ayrıldınız.'); else showMenuMain(); } function returnToMenu(reasonText){ gameStarted = false; Net.cleanupLocal(); clearRemoteBalls(); closeLobby(); hudEl.classList.remove('show'); ctrlEl.classList.remove('show'); hintEl.classList.remove('show'); mpPanelEl.classList.remove('show'); btnQuit.style.display = 'none'; customPanelEl.classList.remove('open'); btnCustom.classList.remove('active'); hideBanner(); startScreen.classList.remove('hidden'); showMenuMain(); if(reasonText) setOnlineStatus(reasonText, 'err'); else setOnlineStatus(''); } /* ---- In-game multiplayer scoreboard ---- */ function updateMpPanel(){ if(Net.mode === 'offline' || !Net.inGame){ mpPanelEl.classList.remove('show'); return; } const roster = Net.roster || []; let html = '
ROOM ' + escapeHtml(Net.code || '') + '' + '
'; html += '
'; roster.forEach(p => { const me = p.id === Net.token; const holed = (p.strokes|0) >= 0 ? '' : ''; html += '
' + '' + '' + escapeHtml(p.name) + (me ? ' •' : '') + '' + '' + (p.strokes|0) + '' + '
'; }); html += '
'; mpPanelEl.innerHTML = html; mpPanelEl.classList.add('show'); } function escapeHtml(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[ch]); } mpPanelEl.addEventListener('click', async (e)=>{ const btn = e.target.closest('[data-copy]'); if(!btn) return; e.stopPropagation(); const ok = await copyText(Net.code || ''); btn.textContent = ok ? 'copied' : 'fail'; setTimeout(()=>{ btn.textContent = 'copy'; }, 1400); }); /* ============================================================ MAIN LOOP ============================================================ */ let lastTime = performance.now(); let accumulator = 0; const STEP = 1/120; function loop(now){ requestAnimationFrame(loop); let dt = (now - lastTime)/1000; lastTime = now; if(dt > 0.12) dt = 0.12; if(dt < 0) dt = 0; accumulator += dt; let steps = 0; while(accumulator >= STEP && steps < 8){ update(STEP); accumulator -= STEP; steps++; } if(steps >= 8) accumulator = 0; if(G.state === 'holed') G.holeTimer -= dt; updateSink(dt); updateCamera(dt); updateAimDirection(); syncMeshes(dt); updateRemoteBalls(dt); updateAimLine(); updateImpacts(dt); updatePowerBar(); netTick(dt); if(flagMesh){ const t = now * 0.001; flagMesh.rotation.y = Math.sin(t*1.35)*0.16 + Math.sin(t*2.9)*0.05; flagMesh.scale.x = 1 + Math.sin(t*3.1)*0.06 + Math.sin(t*7.4)*0.02; flagMesh.scale.y = 1 + Math.sin(t*4.5)*0.025; } if(G.flash > 0){ G.flash = Math.max(0, G.flash - dt*2.6); fadeEl.style.opacity = (G.flash*0.85).toFixed(3); } else if(fadeEl.style.opacity !== '0'){ fadeEl.style.opacity = '0'; } renderer.render(scene, camera); if(gameStarted) monitorPerf(now); } function update(dt){ if(G.state === 'playing'){ updateBall(dt); } else if(G.state === 'holed'){ if(G.holeTimer <= 0){ if(Net.mode === 'offline'){ nextHole(); } else if(Net.mode === 'host'){ G.postHoleWait += dt; if(Net.allHoled() || G.postHoleWait > 20) nextHole(); } // client: hole progression is driven by the host's 'hole' message } } } /* ============================================================ BOOT ============================================================ */ function startGame(opts){ opts = opts || {}; if(gameStarted) return; gameStarted = true; SFX.init(); SFX.resume(); startScreen.classList.add('hidden'); closeLobby(); hudEl.classList.add('show'); ctrlEl.classList.add('show'); hintEl.classList.add('show'); setTimeout(()=>{ hintEl.style.opacity = '0.35'; }, 9000); if(Net.mode !== 'offline'){ btnQuit.style.display = ''; mpPanelEl.classList.add('show'); updateMpPanel(); } else { btnQuit.style.display = 'none'; mpPanelEl.classList.remove('show'); } if(!initThree()) return; buildCustomizeUI(); let seed; if(opts.seed !== undefined && opts.seed !== null) seed = opts.seed; else if(Net.mode !== 'offline') seed = Net.holeSeed; const hole = opts.hole || 1; G.hole = hole; resetHolePresentation(); loadHole(hole, seed); if(Net.mode !== 'offline') updateMpPanel(); requestAnimationFrame(loop); } playBtn.addEventListener('click', ()=>{ startGame({}); }); playBtn.addEventListener('touchend', (e)=>{ e.preventDefault(); startGame({}); }, {passive:false}); document.addEventListener('visibilitychange', ()=>{ if(document.hidden){ SFX.setRoll(0); } else { lastTime = performance.now(); } }); window.addEventListener('beforeunload', ()=>{ if(Net.mode !== 'offline'){ try{ if(Net.mode === 'client' && Net.hostConn && Net.hostConn.open){ Net.hostConn.send({ t:'leave' }); } }catch(e){} } }); let lastTouchEnd = 0; document.addEventListener('touchend', (e)=>{ const now = Date.now(); if(now - lastTouchEnd < 300) e.preventDefault(); lastTouchEnd = now; }, {passive:false}); (function initialResize(){ viewW = window.innerWidth; viewH = window.innerHeight; const dpr = Math.min(window.devicePixelRatio||1, MAX_DPR); canvas.width = Math.round(viewW * dpr); canvas.height = Math.round(viewH * dpr); })(); /* ============================================================ SELF TESTS ------------------------------------------------------------ Run in the browser console: EndlessGolfTests.run() Or open the page with ?test=1 to auto-run and print a table. ============================================================ */ function runSelfTests(){ const results = []; function t(name, fn){ try{ fn(); results.push({ test: name, result: 'PASS' }); } catch(e){ results.push({ test: name, result: 'FAIL', detail: String((e && e.message) || e) }); } } function assert(cond, msg){ if(!cond) throw new Error(msg || 'assertion failed'); } function eq(a, b, msg){ if(a !== b) throw new Error((msg || 'eq') + ': expected ' + b + ', got ' + a); } /* --- code generation & normalisation --- */ t('genCode: length + alphabet', () => { for(let i = 0; i < 500; i++){ const c = genCode(); eq(c.length, NET.CODE_LEN, 'length'); assert(isValidCode(c), 'generated code must validate: ' + c); } }); t('genCode: avoids confusing glyphs', () => { for(let i = 0; i < 800; i++){ const c = genCode(); assert(!/[ILOU]/.test(c), 'code must not contain I/L/O/U: ' + c); } }); t('genCode: no duplicates in 5000 draws', () => { const seen = new Set(); for(let i = 0; i < 5000; i++) seen.add(genCode()); assert(seen.size > 4990, 'expected near-unique codes, got ' + seen.size); }); t('normalizeCode: strips spaces, uppercases, maps confusables', () => { eq(normalizeCode(' ab-cd '), 'ABCD', 'basic strip'); eq(normalizeCode('oil'), '011', 'O→0, I→1, L→1'); eq(normalizeCode(' a b c d e '), 'ABCDE', 'spaces ignored'); eq(normalizeCode(''), '', 'empty'); eq(normalizeCode(null), '', 'null safe'); }); t('isValidCode: rejects bad input', () => { assert(isValidCode('ABCDE') === true, 'valid'); assert(isValidCode('ABCD') === false, 'too short'); assert(isValidCode('ABCDEF') === false, 'too long'); assert(isValidCode('ABCD!') === false, 'bad char'); assert(isValidCode(12345) === false, 'non-string'); assert(isValidCode('ABCDI') === false, 'I not in alphabet'); }); /* --- room creation --- */ t('createLobbyState: host is present and marked host', () => { const l = createLobbyState('ABCDE', 'HOSTTOKEN', 'Alice', { ballColor: '#ff0000' }); eq(l.players.size, 1, 'one player'); const h = l.players.get('HOSTTOKEN'); assert(h && h.host === true, 'host flag'); eq(h.name, 'Alice', 'name'); eq(h.ready, true, 'host always ready'); eq(l.started, false, 'not started'); }); /* --- joining --- */ t('lobbyAddPlayer: adds a new player', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); const r = lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); assert(r.ok, 'join ok'); eq(r.rejoin, false, 'fresh join'); eq(l.players.size, 2, 'two players'); eq(l.players.get('P1').ready, false, 'joiners start not-ready'); }); t('lobbyAddPlayer: duplicate token rejoins, no duplicate entry', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); const r = lobbyAddPlayer(l, { token: 'P1', name: 'Bobby' }); assert(r.ok && r.rejoin, 'rejoin detected'); eq(l.players.size, 2, 'still two players'); eq(l.players.get('P1').name, 'Bobby', 'name updated'); }); t('lobbyAddPlayer: rejects missing token', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); const r = lobbyAddPlayer(l, { name: 'Ghost' }); assert(!r.ok && r.reason === 'bad-token', 'bad token rejected'); eq(l.players.size, 1, 'no phantom player'); }); /* --- capacity --- */ t('capacity: room rejects players past max', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); for(let i = 1; i < NET.MAX_PLAYERS; i++){ const r = lobbyAddPlayer(l, { token: 'P' + i, name: 'P' + i }); assert(r.ok, 'join ' + i + ' should succeed'); } eq(l.players.size, NET.MAX_PLAYERS, 'full'); const over = lobbyAddPlayer(l, { token: 'POVER', name: 'Over' }); assert(!over.ok && over.reason === 'full', 'over-capacity rejected'); }); t('capacity: reconnect of an existing player is allowed when full', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); for(let i = 1; i < NET.MAX_PLAYERS; i++) lobbyAddPlayer(l, { token: 'P' + i, name: 'P' + i }); l.players.get('P1').connected = false; const r = lobbyAddPlayer(l, { token: 'P1', name: 'P1' }); assert(r.ok && r.rejoin, 'reconnect allowed'); }); /* --- readiness rules --- */ t('lobbyCanStart: needs minimum players', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); const chk = lobbyCanStart(l, 'H'); assert(!chk.ok && chk.reason === 'not-ready', 'not-ready while unready'); }); t('lobbyCanStart: all non-host ready → ok', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); lobbySetReady(l, 'P1', true); const chk = lobbyCanStart(l, 'H'); assert(chk.ok, 'should be startable'); }); t('lobbyCanStart: one unready player blocks start', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); lobbyAddPlayer(l, { token: 'P2', name: 'Cara' }); lobbySetReady(l, 'P1', true); const chk = lobbyCanStart(l, 'H'); assert(!chk.ok && chk.reason === 'not-ready', 'blocked'); }); t('lobbyCanStart: disconnected players do not block', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); lobbyAddPlayer(l, { token: 'P2', name: 'Cara' }); lobbySetReady(l, 'P1', true); l.players.get('P2').connected = false; const chk = lobbyCanStart(l, 'H'); assert(chk.ok, 'disconnected player ignored'); }); /* --- host-only start --- */ t('lobbyCanStart: non-host cannot start', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); lobbySetReady(l, 'P1', true); const chk = lobbyCanStart(l, 'P1'); assert(!chk.ok && chk.reason === 'not-host', 'non-host rejected'); }); t('lobbyCanStart: cannot start twice', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); lobbySetReady(l, 'P1', true); l.started = true; const chk = lobbyCanStart(l, 'H'); assert(!chk.ok && chk.reason === 'already-started', 'already started'); }); /* --- departure --- */ t('lobbyRemovePlayer: removes a player, keeps host', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); assert(lobbyRemovePlayer(l, 'P1'), 'removed'); eq(l.players.size, 1, 'one left'); assert(!lobbyRemovePlayer(l, 'H'), 'host cannot be removed'); eq(l.players.size, 1, 'host still there'); }); /* --- timeout cleanup --- */ t('lobbyPrune: removes stale disconnected players only', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); lobbyAddPlayer(l, { token: 'P2', name: 'Cara' }); l.players.get('P1').connected = false; l.players.get('P1').lastSeen = 1000; l.players.get('P2').lastSeen = 1000; const removed = lobbyPrune(l, 1000 + NET.RECONNECT_GRACE + 1, NET.RECONNECT_GRACE); eq(removed.length, 1, 'one pruned'); eq(removed[0], 'P1', 'the disconnected one'); assert(l.players.has('P2'), 'connected player kept'); assert(l.players.has('H'), 'host kept'); }); t('lobbyPrune: recently disconnected players survive', () => { const l = createLobbyState('ABCDE', 'H', 'Alice', null); lobbyAddPlayer(l, { token: 'P1', name: 'Bob' }); l.players.get('P1').connected = false; l.players.get('P1').lastSeen = 5000; const removed = lobbyPrune(l, 5001, NET.RECONNECT_GRACE); eq(removed.length, 0, 'nothing pruned inside grace window'); }); /* --- malformed / invalid messages --- */ t('safeMsg: rejects malformed payloads', () => { eq(safeMsg(null), null, 'null'); eq(safeMsg('hello'), null, 'string'); eq(safeMsg(42), null, 'number'); eq(safeMsg([]), null, 'array'); eq(safeMsg({}), null, 'missing t'); eq(safeMsg({ t: '' }), null, 'empty t'); eq(safeMsg({ t: 'x'.repeat(50) }), null, 'overlong t'); assert(safeMsg({ t: 'ball' }) !== null, 'valid passes'); }); t('validateBallMsg: rejects out-of-range and bogus values', () => { const hole = { bounds: { minX: -100, minY: -100, maxX: 100, maxY: 100 } }; const p = {}; assert(!validateBallMsg({ x: NaN, y: 0, z: 0, s: 0 }, p, hole), 'NaN rejected'); assert(!validateBallMsg({ x: 9999, y: 0, z: 0, s: 0 }, p, hole), 'out of bounds'); assert(!validateBallMsg({ x: 0, y: 0, z: 1e6, s: 0 }, p, hole), 'absurd z'); assert(!validateBallMsg({ x: 0, y: 0, z: 0, s: -5 }, p, hole), 'negative strokes'); assert(validateBallMsg({ x: 0, y: 0, z: 1, s: 0 }, p, hole), 'valid accepted'); }); t('validateBallMsg: enforces monotonic strokes and movement limit', () => { const hole = { bounds: { minX: -500, minY: -500, maxX: 500, maxY: 500 } }; const p = {}; assert(validateBallMsg({ x: 0, y: 0, z: 1, s: 2 }, p, hole), 'first message'); assert(!validateBallMsg({ x: 0, y: 0, z: 1, s: 1 }, p, hole), 'strokes went backwards'); assert(!validateBallMsg({ x: 0, y: 0, z: 1, s: 99 }, p, hole), 'strokes jumped'); assert(!validateBallMsg({ x: 400, y: 400, z: 1, s: 2 }, p, hole), 'teleport rejected'); assert(validateBallMsg({ x: 5, y: 5, z: 1, s: 3 }, p, hole), 'plausible step accepted'); }); t('sanitizeName: trims length and strips control characters', () => { eq(sanitizeName(' Bob '), 'Bob', 'trim'); eq(sanitizeName('A'.repeat(40)).length, 14, 'clamped'); assert(!/[<>]/.test(sanitizeName('