// Zero-dependency sync server (розділ 2): static + SSE broadcast стану сесії. // ponytail: сесії в пам'яті — рестарт сервера скидає гру. Досить для вечора. const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); const PORT = process.env.PORT || 3001; const sessions = Object.create(null); function getSession(code) { return sessions[code] ??= { state: { found: {}, pinned: [], asked: {}, notes: [], crossed: {}, stars: {}, act: 1, tries: 0, lastAccusation: null, solved: false, online: {} }, clients: new Set() }; } function broadcast(s) { const msg = `data: ${JSON.stringify(s.state)}\n\n`; for (const res of s.clients) res.write(msg); } function apply(st, a) { switch (a.t) { case 'find': st.found[a.id] = a.by || true; break; case 'pin': if (!st.pinned.some(p => p.id === a.id)) st.pinned.push({ id: a.id, s: a.s, by: a.by }); break; case 'unpin': st.pinned = st.pinned.filter(p => p.id !== a.id); break; case 'repin': { const p = st.pinned.find(p => p.id === a.id); if (p) p.s = a.s; } break; case 'ask': { const arr = st.asked[a.s] ??= []; if (!arr.includes(a.q)) arr.push(a.q); } break; case 'note': if (typeof a.txt === 'string' && a.txt.trim()) st.notes.push({ id: Date.now() + Math.random(), txt: a.txt.slice(0, 500), by: a.by }); break; case 'note_del':st.notes = st.notes.filter(n => n.id !== a.id); break; case 'cross': st.crossed[a.s] = !st.crossed[a.s]; break; case 'star': st.stars[a.id] = !st.stars[a.id]; break; case 'advance': st.act = Math.max(st.act, a.act | 0); break; case 'accuse': st.tries++; st.lastAccusation = { suspect: a.suspect, method: a.method, evidence: a.evidence, result: a.result, n: st.tries }; if (a.result.ok) st.solved = true; break; } } const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }; const server = http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); if (u.pathname === '/events') { const s = getSession(u.searchParams.get('s') || 'DEFAULT'); const role = u.searchParams.get('role'); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); s.clients.add(res); if (role) s.state.online[role] = (s.state.online[role] || 0) + 1; broadcast(s); req.on('close', () => { s.clients.delete(res); if (role) { s.state.online[role] = Math.max(0, (s.state.online[role] || 1) - 1); broadcast(s); } }); return; } if (u.pathname === '/act' && req.method === 'POST') { let body = ''; req.on('data', c => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', () => { try { const a = JSON.parse(body); const s = getSession(a.session || 'DEFAULT'); apply(s.state, a); broadcast(s); res.writeHead(200).end('ok'); } catch { res.writeHead(400).end('bad request'); } }); return; } const file = { '/': 'index.html', '/index.html': 'index.html', '/case2.js': 'case2.js', '/minigames.js': 'minigames.js' }[u.pathname]; if (!file) return void res.writeHead(404).end('not found'); fs.readFile(path.join(__dirname, file), (err, data) => { if (err) return void res.writeHead(500).end('error'); res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] }).end(data); }); }); server.listen(PORT, () => { console.log(`Тихий аукціон (розділ 2) — сервер запущено.\n`); console.log(`На цьому пристрої: http://localhost:${PORT}`); for (const ifs of Object.values(os.networkInterfaces())) for (const i of ifs) if (i.family === 'IPv4' && !i.internal) console.log(`У локальній мережі: http://${i.address}:${PORT}`); console.log(`\nОбидва гравці відкривають адресу мережі, вводять однаковий код сесії і обирають різні ролі.`); });