commit bdd85db8a75172988071d0cc012679b55cdb561e Author: Ole Date: Sat Jun 13 21:18:08 2026 +0200 Initial commit: P2P coin flip with QR signaling and commit-reveal protocol diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c1b926 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# randomp2p + +**Vertrauensloser Multiplayer-Münzwurf – P2P im Browser, keine Server, kein Trust.** + +Zwei bis N Spieler verbinden sich via WebRTC (simple-peer) und führen ein kryptografisches Commit-Reveal-Protokoll aus, um einen garantiert fairen Münzwurf zu erhalten. Solange **ein** Teilnehmer ehrlich ist, ist das Ergebnis zufällig und manipulationssicher. + +## Features + +- **P2P** – Kein Server, kein Account, keine zentrale Instanz +- **Trustless** – Commit-Reveal-Protokoll mit SHA-256-Bindung, jeder verifiziert +- **QR-Bootstrapping** – SDP-Offers/Answers werden als QR-Codes ausgetauscht +- **Multiplayer (2–n)** – Beliebig viele Teilnehmer in einer Runde +- **Coin-Animation** – 3D-Münzwurf via CSS +- **100% Vanilla JS** – Keine Build-Tools, kein npm, keine Abhängigkeiten außer CDN-Libs + +## Quick Start + +```bash +git clone +cd randomp2p +python3 -m http.server 8080 +``` + +Dann auf zwei (oder mehr) Geräten `http://localhost:8080` öffnen. + +> WebRTC + Kamera (`getUserMedia`) + Web Crypto API brauchen einen **sicheren Kontext**. Lokal via localhost funktioniert das. Für andere Geräte im selben Netzwerk die lokale IP verwenden. + +## Spielanleitung + +### Host + +1. **"Raum erstellen"** – QR-Code mit SDP Offer wird angezeigt +2. **"Antwort-QR scannen"** – Kamera startet, scannt die Antwort-QRs der Beitreter +3. Jeder neue Spieler erscheint in der Liste +4. **"Münzwurf starten"** – Übergang in den Spiel-Screen +5. **"Münzwurf starten"** – Protokoll beginnt (Commit → Reveal → Ergebnis) + +### Beitreter + +1. **"Raum beitreten"** – Kamera startet +2. QR des Hosts scannen +3. **Antwort-QR zeigen** – Host scannt diesen QR +4. Verbindung steht, warten auf Start +5. Protokoll läuft automatisch durch – Ergebnis erscheint + +### Protokoll-Phasen + +| Phase | Beschreibung | +|-------|-------------| +| **Commit** | Jeder Spieler generiert 32 Zufallsbytes und sendet den SHA-256-Hash an alle | +| **Warten** | Sammle Commits aller Teilnehmer | +| **Reveal** | Jeder sendet die rohen Zufallsbytes an alle | +| **Verify** | Jeder prüft: SHA-256(received) == stored_commit | +| **Result** | XOR aller Secrets → Parity (0=Kopf, 1=Zahl) | + +## Architektur + +``` +randomp2p/ +├── index.html – 5 Screens + CDN-Libs +├── style.css – Dark-Theme, Coin-Animation +├── crypto.js – SHA-256, Zufallsbytes, XOR-Combine +├── p2p.js – MeshNet: simple-peer, QR-Signaling, Datenkanal +├── protocol.js – Commit-Reveal State Machine +└── app.js – UI-Routing, Kamera/QR-Scan, Verkabelung +``` + +### Abhängigkeiten (CDN) + +| Library | Zweck | +|---------|-------| +| [simple-peer](https://github.com/feross/simple-peer) | WebRTC-Datenkanäle | +| [qrcodejs](https://github.com/davidshimjs/qrcodejs) | QR-Code-Generierung | +| [jsQR](https://github.com/cozmo/jsQR) | QR-Code-Scanning per Kamera | + +## Netzwerk-Topologie + +**Best Case (gleiches WiFi):** +QR-SDP-Austausch zwischen Host und jedem Peer. Host teilt IP-Liste → potentiell volles Mesh über `signal_relay`. + +**Worst Case (NAT/Internet):** +Stern-Topologie über den Host. Protokoll funktioniert trotzdem – die Fairness ist nicht von der Topologie abhängig. + +## Warum ist das fair? + +Das Commit-Reveal-Protokoll garantiert: + +1. **Keine späte Manipulation:** Der SHA-256-Commit bindet jeden Spieler vor dem Reveal an sein Secret (Preimage-Resistenz) +2. **Keine Absprache nötig:** Solange **ein** Teilnehmer ehrlich Zufallsbytes beisteuert, ist das XOR-Ergebnis zufällig +3. **Volle Transparenz:** Jeder Teilnehmer rechnet lokal alle Prüfungen und das Endergebnis + +Mathematisch: `result = S_1 ⊕ S_2 ⊕ ... ⊕ S_n`. Wenn `S_k` echt zufällig und vor `S_1..S_{k-1}, S_{k+1}..S_n` festgelegt wurde, ist `result` zufällig – unabhängig von allen anderen Secrets. + +## IPFS-Deployment + +```bash +ipfs add -r . +# → CID notieren, über IPFS-Gateway aufrufbar +# → Gateway-URL als QR-Code in die App einbauen +``` + +## Ausblick / TODOs + +- [ ] Volles Mesh: Peers verbinden sich direkt via relayed signaling +- [ ] Timeout + Reconnect bei Verbindungsabbruch +- [ ] Raum-Code als Alternative zum QR-Scan +- [ ] TURN-Server-Konfiguration für NAT-Traversal +- [ ] Mehrere Runden mit Historie +- [ ] PWA-Manifest + ServiceWorker für IPFS-Distribution + +## Lizenz + +MIT diff --git a/app.js b/app.js new file mode 100644 index 0000000..7a381a9 --- /dev/null +++ b/app.js @@ -0,0 +1,286 @@ +(function() { + 'use strict'; + + let mesh = null; + let protocol = null; + let role = null; + let scanningHost = false; + + const $ = id => document.getElementById(id); + + /* ─── Screen routing ─── */ + function show(name) { + ['start','host','join','game','result'].forEach(s => + $(`screen-${s}`).classList.toggle('active', s === name) + ); + } + + /* ─── Player list ─── */ + function renderPlayers(containerId) { + const list = $(containerId); + if (!list) return; + const all = [mesh.myPeerId, ...mesh.getPeerIds()]; + const seen = new Set(); + list.innerHTML = ''; + all.forEach(id => { + if (seen.has(id)) return; + seen.add(id); + const isMe = id === mesh.myPeerId; + const li = document.createElement('li'); + li.className = 'player-chip' + (isMe ? ' self' : ''); + li.innerHTML = `${isMe ? 'Du' : id.slice(0, 8)}`; + list.appendChild(li); + }); + } + + /* ─── QR ─── */ + let qrHost = null; + function showQR(containerId, data) { + const c = $(containerId); + if (!c) return; + c.innerHTML = ''; + qrHost = new QRCode(c, { text: data, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.L }); + } + + /* ─── Camera ─── */ + let camStream = null; + let camRaf = null; + + async function camStart(videoId) { + await camStop(); + camStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment', width: { ideal: 320 }, height: { ideal: 240 } } + }); + const v = $(videoId); + v.srcObject = camStream; + v.setAttribute('playsinline', ''); + v.classList.add('active'); + await v.play(); + return v; + } + + function camStop() { + if (camRaf) { cancelAnimationFrame(camRaf); camRaf = null; } + if (camStream) { camStream.getTracks().forEach(t => t.stop()); camStream = null; } + } + + function camScan(videoId, canvasId, callback) { + const v = $(videoId); + const c = $(canvasId); + const ctx = c.getContext('2d'); + camRaf = requestAnimationFrame(function tick() { + if (!v.videoWidth) { camRaf = requestAnimationFrame(tick); return; } + c.width = v.videoWidth; c.height = v.videoHeight; + ctx.drawImage(v, 0, 0); + const img = ctx.getImageData(0, 0, c.width, c.height); + const code = jsQR(img.data, img.width, img.height); + if (code && code.data) { + try { + const p = JSON.parse(code.data); + if (p.v === 1) { callback(p, code.data); return; } + } catch(e) {} + } + camRaf = requestAnimationFrame(tick); + }); + } + + /* ─── Coin animation ─── */ + function flipCoin(resultBit, areaId) { + const area = $(areaId); + if (!area) return; + area.hidden = false; + const coin = area.querySelector('.coin'); + coin.classList.remove('flipping','result-kopf','result-zahl'); + void coin.offsetWidth; + coin.classList.add('flipping'); + setTimeout(() => { + coin.classList.remove('flipping'); + coin.classList.add(resultBit === 0 ? 'result-kopf' : 'result-zahl'); + }, 1500); + } + + /* ─── Protocol phases UI ─── */ + function updatePhases(state) { + const map = { IDLE:'ready', STARTING:'ready', COMMITTING:'commit', + WAITING_FOR_COMMITS:'commit', REVEALING:'reveal', + WAITING_FOR_REVEALS:'reveal', VERIFYING:'verify', COMPLETE:'done' }; + const active = map[state] || 'ready'; + let doneSeen = false; + ['ready','commit','reveal','verify','done'].forEach(p => { + const el = $('phase-'+p); + if (!el) return; + el.classList.toggle('active', p === active && !doneSeen); + el.classList.toggle('done', doneSeen); + if (p === active) doneSeen = true; + }); + } + + /* ─── Game setup ─── */ + function setupGame(autostart) { + show('game'); + renderPlayers('game-player-list'); + $('btn-start-protocol').hidden = autostart || role !== 'host'; + $('coin-area').hidden = true; + $('result-area').hidden = true; + $('protocol-text').textContent = autostart ? 'Starte Protokoll...' : 'Bereit zum Münzwurf'; + updatePhases('IDLE'); + + protocol = new CoinFlipProtocol(mesh); + + protocol.onstatechange = updatePhases; + protocol.onprogress = (phase, rcvd, total) => { + $('protocol-text').textContent = + phase === 'commit' ? `Commits: ${rcvd}/${total}` : `Reveals: ${rcvd}/${total}`; + }; + protocol.oncomplete = result => { + const text = result === 0 ? 'KOPF' : 'ZAHL'; + $('protocol-text').textContent = `Ergebnis: ${text}!`; + flipCoin(result, 'coin-area'); + $('result-area').hidden = false; + $('result-text').textContent = text; + $('btn-start-protocol').hidden = true; + }; + protocol.onerror = err => { + $('protocol-text').textContent = 'Fehler: ' + err; + }; + + mesh.ondata = (peerId, msg) => { + if (msg.type === 'protocol_start' && protocol && protocol.state === 'IDLE') { + $('btn-start-protocol').hidden = true; + protocol.start(); + return; + } + if (msg.type === 'protocol' && protocol) { + protocol.handleMessage(peerId, msg); + } + }; + + if (autostart) { + protocol.start(); + } + } + + /* ─── Start screen ─── */ + $('btn-create').addEventListener('click', () => { + role = 'host'; + mesh = new MeshNet(); + + mesh.onpeerconnect = pid => { + renderPlayers('host-player-list'); + $('btn-start-game').disabled = mesh.getCount() < 1; + }; + mesh.onpeerdisconnect = () => { + renderPlayers('host-player-list'); + $('btn-start-game').disabled = mesh.getCount() < 1; + }; + mesh.onqrupdate = d => showQR('qr-host', d); + + mesh.createRoom(); + show('host'); + renderPlayers('host-player-list'); + $('btn-start-game').disabled = true; + }); + + /* ─── Host: scan answer QR ─── */ + $('btn-scan-answer').addEventListener('click', async () => { + if (camStream) { camStop(); return; } + try { + await camStart('host-camera'); + $('host-scan-status').textContent = 'Warte auf Antwort-QR...'; + scanningHost = true; + camScan('host-camera', 'host-camera-canvas', (parsed, raw) => { + if (!parsed.s || !parsed.id) return; + if (parsed.to && parsed.to !== mesh.myPeerId) return; + camStop(); + scanningHost = false; + $('host-scan-status').textContent = 'Verbunden!'; + mesh.feedAnswer(raw); + $('btn-scan-answer').textContent = 'Antwort-QR scannen'; + }); + $('btn-scan-answer').textContent = 'Scannen beenden'; + } catch (e) { + $('host-scan-status').textContent = 'Kamera-Fehler'; + } + }); + + /* ─── Join screen ─── */ + $('btn-join').addEventListener('click', async () => { + role = 'peer'; + mesh = new MeshNet(); + show('join'); + $('join-answer-section').hidden = true; + $('join-scan-status').textContent = 'QR-Code scannen...'; + + mesh.onanswerready = answerData => { + $('join-answer-section').hidden = false; + showQR('qr-answer', answerData); + $('join-scan-status').textContent = 'Antwort-QR dem Host zeigen'; + camStop(); + }; + + mesh.onpeerconnect = () => { + renderPlayers('join-player-list'); + $('join-scan-status').textContent = 'Verbunden – warte auf Start...'; + }; + + mesh.ondata = (peerId, msg) => { + if (msg.type === 'protocol_start') { + setupGame(true); + } + }; + + try { + await camStart('join-camera'); + let scanned = false; + camScan('join-camera', 'join-camera-canvas', (parsed, raw) => { + if (scanned) return; + if (!parsed.s || !parsed.id) return; + scanned = true; + $('join-scan-status').textContent = 'Verbinde...'; + mesh.joinFromQR(raw); + }); + } catch (e) { + $('join-scan-status').textContent = 'Kamera nicht verfügbar'; + } + }); + + /* ─── Host: start game button (in host screen) ─── */ + $('btn-start-game').addEventListener('click', () => { + setupGame(false); + }); + + /* ─── Host: start protocol button (in game screen) ─── */ + $('btn-start-protocol').addEventListener('click', async () => { + if (!mesh || !protocol) return; + mesh.broadcast({ type: 'protocol_start' }); + $('btn-start-protocol').hidden = true; + await protocol.start(); + }); + + /* ─── Retry / again ─── */ + $('btn-retry').addEventListener('click', () => { + if (protocol) protocol.reset(); + $('coin-area').hidden = true; + $('result-area').hidden = true; + $('btn-start-protocol').hidden = role !== 'host'; + $('protocol-text').textContent = 'Bereit zum Münzwurf'; + updatePhases('IDLE'); + }); + + /* ─── Result screen → play again ─── */ + $('btn-play-again').addEventListener('click', () => { + if (protocol) protocol.reset(); + $('coin-area').hidden = true; + $('result-area').hidden = true; + setupGame(false); + }); + + /* ─── Back to start ─── */ + $('btn-back-start').addEventListener('click', () => { + if (mesh) { mesh.destroy(); mesh = null; } + protocol = null; + camStop(); + show('start'); + }); + +})(); diff --git a/crypto.js b/crypto.js new file mode 100644 index 0000000..6a70c34 --- /dev/null +++ b/crypto.js @@ -0,0 +1,61 @@ +function generatePeerId() { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +async function generateSecret() { + const secret = new Uint8Array(32); + crypto.getRandomValues(secret); + return secret; +} + +async function commit(secret) { + const hash = await crypto.subtle.digest('SHA-256', secret); + return new Uint8Array(hash); +} + +function arraysEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} + +function combine(secrets) { + const result = new Uint8Array(32); + for (const secret of secrets) { + for (let i = 0; i < 32; i++) { + result[i] ^= secret[i]; + } + } + return result[0] & 1; +} + +function bufToBase64(buf) { + const bytes = new Uint8Array(buf); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToBuf(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function hexToBuf(hex) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..fd74221 --- /dev/null +++ b/index.html @@ -0,0 +1,118 @@ + + + + + + randomp2p – Münzwurf + + + + + +
+
+

randomp2p

+

Vertrauensloser Münzwurf mit Freunden

+
+ + +
+
+
+ + +
+
+

Dein Raum

+
+
+

Scannen lassen, um beizutreten

+
+
+

Spieler

+
    +
    +
    + + + +

    +
    + +
    +
    + + +
    +
    +

    Raum beitreten

    +
    + + +

    QR-Code scannen...

    +
    + +
      +
      +
      + + +
      +
      +

      Münzwurf

      +
      +
        +
        +
        +
        Bereit
        +
        Commit
        +
        Reveal
        +
        Verifikation
        +
        Ergebnis
        +
        +
        Warte auf Start...
        + + + +
        +
        + + +
        +
        +
        +
        +
        K
        +
        Z
        +
        +
        +

        +

        + + +
        +
        + + + + + + + + + + diff --git a/p2p.js b/p2p.js new file mode 100644 index 0000000..488ffeb --- /dev/null +++ b/p2p.js @@ -0,0 +1,217 @@ +class MeshNet { + constructor() { + this.myPeerId = generatePeerId(); + this.connections = new Map(); + this.isHost = false; + this._pendingPeer = null; + this._currentQRData = null; + + this.onpeerconnect = null; + this.onpeerdisconnect = null; + this.ondata = null; + this.onqrupdate = null; + this.onanswerready = null; + } + + createRoom() { + this.isHost = true; + this._createPendingPeer(); + } + + _createPendingPeer() { + if (this._pendingPeer) { + try { this._pendingPeer.peer.destroy(); } catch (e) {} + } + + const peer = new SimplePeer({ initiator: true, trickle: false }); + let signalSent = false; + let connected = false; + + peer.on('signal', signal => { + if (signalSent) return; + signalSent = true; + this._currentQRData = JSON.stringify({ v: 1, id: this.myPeerId, s: signal }); + if (this.onqrupdate) this.onqrupdate(this._currentQRData); + }); + + peer.on('connect', () => { + connected = true; + peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId })); + }); + + peer.on('data', data => { + try { + const msg = JSON.parse(data.toString()); + this._handleMessage(msg, peer); + } catch (e) { + console.error('p2p data parse error:', e); + } + }); + + peer.on('close', () => { + this._cleanupPeer(peer); + }); + + peer.on('error', err => { + console.error('p2p error:', err); + }); + + this._pendingPeer = { peer, signalSent, connected, peerId: null }; + } + + feedAnswer(answerStr) { + if (!this._pendingPeer || this._pendingPeer.signalReceived) return; + try { + const data = JSON.parse(answerStr); + this._pendingPeer.peerId = data.id; + this._pendingPeer.peer.signal(data.s); + this._pendingPeer.signalReceived = true; + } catch (e) { + console.error('feedAnswer error:', e); + } + } + + joinFromQR(qrContent) { + const data = JSON.parse(qrContent); + const remoteId = data.id; + const offer = data.s; + + const peer = new SimplePeer({ initiator: false, trickle: false }); + let answerSent = false; + let connected = false; + + peer.on('signal', signal => { + if (answerSent) return; + answerSent = true; + const answerData = JSON.stringify({ + v: 1, id: this.myPeerId, s: signal, to: remoteId + }); + if (this.onanswerready) this.onanswerready(answerData); + }); + + peer.on('connect', () => { + connected = true; + this.connections.set(remoteId, peer); + peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId })); + if (this.onpeerconnect) this.onpeerconnect(remoteId); + }); + + peer.on('data', data => { + try { + const msg = JSON.parse(data.toString()); + this._handleMessage(msg, peer); + } catch (e) { + console.error('p2p data parse error:', e); + } + }); + + peer.on('close', () => { + this._cleanupPeer(peer); + }); + + peer.on('error', err => { + console.error('p2p error:', err); + }); + + peer.signal(offer); + this.connections.set(remoteId, peer); + this._pendingPeer = { peer, peerId: remoteId, connected }; + } + + _handleMessage(msg, peer) { + if (msg.type === 'identity') { + const existing = this._findPeerId(peer); + if (existing && existing !== msg.peerId) { + this.connections.delete(existing); + } + this.connections.set(msg.peerId, peer); + + if (this._pendingPeer && this._pendingPeer.peer === peer) { + this._pendingPeer.peerId = msg.peerId; + this._pendingPeer.connected = true; + if (this.onpeerconnect) this.onpeerconnect(msg.peerId); + if (this.isHost) { + setTimeout(() => this._createPendingPeer(), 300); + } + } else { + if (this.onpeerconnect) this.onpeerconnect(msg.peerId); + } + return; + } + + if (msg.type === 'signal_relay') { + const target = this.connections.get(msg.to); + if (target) { + target.send(JSON.stringify({ + type: 'signal_relayed', + from: msg.from, + signal: msg.signal + })); + } + return; + } + + if (this.ondata) { + const peerId = this._findPeerId(peer) || 'unknown'; + this.ondata(peerId, msg); + } + } + + _findPeerId(peer) { + for (const [id, p] of this.connections) { + if (p === peer) return id; + } + return null; + } + + _cleanupPeer(peer) { + const peerId = this._findPeerId(peer); + if (peerId) { + this.connections.delete(peerId); + if (this.onpeerdisconnect) this.onpeerdisconnect(peerId); + } + if (this._pendingPeer && this._pendingPeer.peer === peer) { + this._pendingPeer = null; + } + } + + broadcast(msg) { + const str = JSON.stringify(msg); + for (const [id, peer] of this.connections) { + try { peer.send(str); } catch (e) { console.error('broadcast to', id, 'failed:', e); } + } + } + + sendTo(peerId, msg) { + const peer = this.connections.get(peerId); + if (peer) { + try { peer.send(JSON.stringify(msg)); } catch (e) { console.error('sendTo', peerId, 'failed:', e); } + } + } + + relaySignal(from, to, signal) { + const target = this.connections.get(to); + if (target) { + target.send(JSON.stringify({ type: 'signal_relay', from, to, signal })); + } + } + + getPeerIds() { + return Array.from(this.connections.keys()); + } + + getCount() { + return this.connections.size; + } + + destroy() { + for (const peer of this.connections.values()) { + try { peer.destroy(); } catch (e) {} + } + this.connections.clear(); + if (this._pendingPeer) { + try { this._pendingPeer.peer.destroy(); } catch (e) {} + this._pendingPeer = null; + } + } +} diff --git a/protocol.js b/protocol.js new file mode 100644 index 0000000..8abf6a8 --- /dev/null +++ b/protocol.js @@ -0,0 +1,109 @@ +class CoinFlipProtocol { + constructor(mesh) { + this.mesh = mesh; + this.state = 'IDLE'; + this.secret = null; + this.commits = new Map(); + this.reveals = new Map(); + this.result = null; + this.totalPeers = 0; + + this.onstatechange = null; + this.onprogress = null; + this.oncomplete = null; + this.onerror = null; + } + + async start() { + if (this.state !== 'IDLE') return; + this.state = 'STARTING'; + this.totalPeers = this.mesh.getCount() + 1; + this.secret = await generateSecret(); + const myCommit = await commit(this.secret); + this.commits.set(this.mesh.myPeerId, myCommit); + if (this.onprogress) this.onprogress('commit', 1, this.totalPeers); + if (this.onstatechange) this.onstatechange('COMMITTING'); + this.mesh.broadcast({ type: 'protocol', phase: 'commit', data: bufToBase64(myCommit) }); + this.state = 'WAITING_FOR_COMMITS'; + if (this.onstatechange) this.onstatechange('WAITING_FOR_COMMITS'); + this._checkCommits(); + } + + handleMessage(peerId, msg) { + if (msg.type !== 'protocol') return; + + if (msg.phase === 'commit' && (this.state === 'WAITING_FOR_COMMITS' || this.state === 'COMMITTING' || this.state === 'STARTING')) { + if (!this.commits.has(peerId)) { + this.commits.set(peerId, base64ToBuf(msg.data)); + if (this.onprogress) this.onprogress('commit', this.commits.size, this.totalPeers); + this._checkCommits(); + } + } + + if (msg.phase === 'reveal' && (this.state === 'WAITING_FOR_REVEALS' || this.state === 'REVEALING')) { + if (!this.reveals.has(peerId)) { + this.reveals.set(peerId, base64ToBuf(msg.data)); + if (this.onprogress) this.onprogress('reveal', this.reveals.size, this.totalPeers); + this._checkReveals(); + } + } + } + + _checkCommits() { + if (this.state !== 'WAITING_FOR_COMMITS') return; + if (this.commits.size >= this.totalPeers) { + this._allCommitsReceived(); + } + } + + async _allCommitsReceived() { + this.state = 'REVEALING'; + if (this.onstatechange) this.onstatechange('REVEALING'); + this.mesh.broadcast({ type: 'protocol', phase: 'reveal', data: bufToBase64(this.secret) }); + this.reveals.set(this.mesh.myPeerId, this.secret); + if (this.onprogress) this.onprogress('reveal', 1, this.totalPeers); + this.state = 'WAITING_FOR_REVEALS'; + if (this.onstatechange) this.onstatechange('WAITING_FOR_REVEALS'); + this._checkReveals(); + } + + _checkReveals() { + if (this.state !== 'WAITING_FOR_REVEALS') return; + if (this.reveals.size >= this.totalPeers) { + this._allRevealsReceived(); + } + } + + async _allRevealsReceived() { + this.state = 'VERIFYING'; + if (this.onstatechange) this.onstatechange('VERIFYING'); + + for (const [peerId, secret] of this.reveals) { + if (peerId === this.mesh.myPeerId) continue; + const commitment = this.commits.get(peerId); + if (!commitment) { + if (this.onerror) this.onerror(`Fehler: Kein Commit von ${peerId}`); + return; + } + const expected = await commit(secret); + if (!arraysEqual(expected, commitment)) { + if (this.onerror) this.onerror(`Betrug erkannt! ${peerId} hat gefälscht.`); + return; + } + } + + this.state = 'COMPLETE'; + this.result = combine(Array.from(this.reveals.values())); + if (this.onstatechange) this.onstatechange('COMPLETE'); + if (this.oncomplete) this.oncomplete(this.result); + } + + reset() { + this.state = 'IDLE'; + this.secret = null; + this.commits.clear(); + this.reveals.clear(); + this.result = null; + this.totalPeers = 0; + } +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..1804cd9 --- /dev/null +++ b/style.css @@ -0,0 +1,330 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #0d1117; + --bg-card: #161b22; + --bg-hover: #1c2333; + --text: #c9d1d9; + --text-dim: #8b949e; + --accent: #238636; + --accent-hover: #2ea043; + --border: #30363d; + --gold: #f0c040; + --silver: #c0c0c0; + --radius: 12px; + --shadow: 0 4px 24px rgba(0,0,0,0.4); +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + overflow: hidden; +} + +/* Screens */ +.screen { + display: none; + height: 100%; + align-items: center; + justify-content: center; + padding: 20px; +} +.screen.active { + display: flex; +} +.screen-inner { + width: 100%; + max-width: 420px; + text-align: center; +} + +h1 { + font-size: 2rem; + font-weight: 800; + background: linear-gradient(135deg, var(--gold), #e0a030); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: 8px; +} +h2 { + font-size: 1.4rem; + margin-bottom: 16px; +} +h3 { + font-size: 1rem; + color: var(--text-dim); + margin-bottom: 8px; +} +.subtitle { + color: var(--text-dim); + margin-bottom: 32px; +} + +/* Buttons */ +.btn-group { + display: flex; + flex-direction: column; + gap: 12px; +} +.btn-primary, .btn-secondary { + padding: 14px 24px; + border: none; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, opacity 0.2s; +} +.btn-primary { + background: var(--accent); + color: #fff; +} +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.btn-secondary { + background: var(--bg-card); + color: var(--text); + border: 1px solid var(--border); +} +.btn-secondary:hover { + background: var(--bg-hover); +} + +/* QR section */ +.qr-section { + background: #fff; + border-radius: var(--radius); + padding: 16px; + display: inline-block; + margin-bottom: 16px; +} +.qr-section canvas, .qr-section img { + display: block; + margin: 0 auto; +} +.hint { + color: var(--text-dim); + font-size: 0.85rem; + margin-top: 8px; +} + +/* Player list */ +.player-section { + margin: 16px 0; +} +.player-list { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} +.player-chip { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 6px 16px; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 6px; +} +.player-chip .dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} +.player-chip .dot.online { + background: var(--accent); +} +.player-chip .dot.waiting { + background: #f0c040; +} +.player-chip.self { + border-color: var(--accent); +} + +/* Camera section */ +.camera-section { + position: relative; + margin-bottom: 16px; +} +.camera-section video { + width: 100%; + max-width: 320px; + border-radius: var(--radius); + border: 2px solid var(--border); +} + +/* Protocol status */ +.protocol-status { + display: flex; + justify-content: center; + gap: 4px; + margin: 20px 0; +} +.phase { + padding: 6px 12px; + border-radius: 20px; + font-size: 0.75rem; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-dim); + transition: all 0.3s; +} +.phase.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.phase.done { + background: #1a4a1a; + border-color: var(--accent); + color: var(--accent); +} + +.protocol-text { + margin: 12px 0; + color: var(--text-dim); + font-size: 0.9rem; +} + +/* Coin flip animation */ +.coin { + width: 120px; + height: 120px; + margin: 24px auto; + perspective: 600px; + cursor: default; +} +.coin.large { + width: 160px; + height: 160px; +} +.coin-inner { + width: 100%; + height: 100%; + position: relative; + transform-style: preserve-3d; + transition: transform 1.5s cubic-bezier(0.22, 1, 0.36, 1); +} +.coin-face { + position: absolute; + width: 100%; + height: 100%; + border-radius: 50%; + backface-visibility: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + font-weight: 900; + color: #333; +} +.coin.large .coin-face { + font-size: 3.5rem; +} +.coin-face.front { + background: radial-gradient(circle at 40% 35%, #ffe066, #f0c040 50%, #c89020); + border: 4px solid #b8860b; + transform: rotateY(0deg); +} +.coin-face.back { + background: radial-gradient(circle at 40% 35%, #e8e8e8, #c0c0c0 50%, #909090); + border: 4px solid #707070; + transform: rotateY(180deg); +} +.coin.flipping .coin-inner { + animation: coinFlip 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} +@keyframes coinFlip { + 0% { transform: rotateY(0deg); } + 60% { transform: rotateY(720deg); } + 80% { transform: rotateY(800deg); } + 100% { transform: rotateY(720deg); } +} +.coin.result-kopf .coin-inner { + transform: rotateY(0deg); +} +.coin.result-zahl .coin-inner { + transform: rotateY(180deg); +} + +/* Result area */ +#result-area { + margin-top: 24px; +} +#result-text { + font-size: 2rem; + margin-bottom: 16px; +} +#result-title { + font-size: 3rem; + margin: 24px 0 8px; +} +#result-detail { + color: var(--text-dim); + margin-bottom: 24px; + font-size: 0.9rem; +} + +/* Host camera (scan answer) */ +#host-scan-answer { + margin: 12px 0; +} +#host-camera { + width: 100%; + max-width: 240px; + border-radius: var(--radius); + display: none; + margin: 8px auto; +} +#host-camera.active { + display: block; +} +#host-scan-status { + color: var(--text-dim); + font-size: 0.85rem; +} + +/* Join answer QR */ +#join-answer-section { + margin: 16px 0; +} +#join-answer-section .qr-section { + background: #fff; + border-radius: var(--radius); + padding: 16px; + display: inline-block; +} + +/* Responsive */ +@media (max-width: 480px) { + .screen { + padding: 16px; + } + h1 { font-size: 1.6rem; } + .phase { + font-size: 0.65rem; + padding: 4px 8px; + } + .coin { width: 100px; height: 100px; } + .coin.large { width: 140px; height: 140px; } + .coin-face { font-size: 2rem; } + .coin.large .coin-face { font-size: 3rem; } +} + +/* Utility */ +.hidden { display: none !important; }