diff --git a/e2e/game.spec.js b/e2e/game.spec.js index 7019f47..69f9e74 100644 --- a/e2e/game.spec.js +++ b/e2e/game.spec.js @@ -54,3 +54,15 @@ test('login fits a narrow viewport and protected pages redirect',async({page})=> expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true); await expect(page.getByLabel('Username',{exact:true})).toBeVisible(); }); +test('secret shortcut displays every staged cue', async ({page}) => { + await register(page, `cue-${Date.now()}`); + await page.keyboard.press('Alt'); + await page.keyboard.press('j'); + await expect(page.locator('#secret-cue')).toHaveText('J'); + await page.keyboard.press('f'); + await expect(page.locator('#secret-cue')).toHaveText('F'); + await expect(page.locator('#secret-cue')).toHaveClass(/secret-cue-strong/); + await page.keyboard.press('k'); + await expect(page.locator('#secret-cue')).toHaveText('K'); + await expect(page.locator('#secret-flash')).toHaveClass(/secret-flash-active/); +}); diff --git a/lib/http.js b/lib/http.js index 6e461bb..3a22213 100644 --- a/lib/http.js +++ b/lib/http.js @@ -53,6 +53,11 @@ function createHttpApp(repository, sessions, lobbies, config) { const users = await repository.users(); const index = users.findIndex(entry => entry.username === user.username); res.json({leaderboard: users.slice(0, 10), self: {...users[index], position: index + 1}}); })); + app.post('/easter-egg', authorized(async (req, res, user) => { + const code = req.body?.code; + if (!['ILIKEMONEY', 'FRETUX', 'KRISHD', 'LOSERNOOB'].includes(code)) throw new AppError('INVALID_EASTER_EGG', 'Unknown terminal command.', 400); + res.json(await repository.redeemEasterEgg(user.username, code)); + })); for (const [route, type] of [['/lobby/create', 'lobby:create'], ['/lobby/join', 'lobby:join'], ['/lobby/leave', 'lobby:leave'], ['/poker/start', 'poker:start'], ['/poker/action', 'poker:action']]) { app.post(route, authorized(async (req, res, user) => { const message = {...req.body, type}; diff --git a/lib/repository.js b/lib/repository.js index 5c294b8..138daf3 100644 --- a/lib/repository.js +++ b/lib/repository.js @@ -29,11 +29,11 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) } await transaction(async tx => { const {user_version: version} = await tx.get('PRAGMA user_version'); - if (version > 1) throw new Error('Database schema is newer than this application'); - if (version === 1) return; - const old = await tx.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'"); - if (old) await tx.exec('ALTER TABLE users RENAME TO legacy_users'); - await tx.exec(` + if (version > 4) throw new Error('Database schema is newer than this application'); + if (version === 0) { + const old = await tx.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'"); + if (old) await tx.exec('ALTER TABLE users RENAME TO legacy_users'); + await tx.exec(` CREATE TABLE users ( username TEXT PRIMARY KEY COLLATE NOCASE, password_hash TEXT NOT NULL, @@ -44,12 +44,28 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) CREATE TABLE commands (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at INTEGER NOT NULL); CREATE TABLE ledger (id INTEGER PRIMARY KEY, command_id TEXT NOT NULL, username TEXT NOT NULL REFERENCES users(username), delta INTEGER NOT NULL, reason TEXT NOT NULL); CREATE TABLE migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL); + CREATE TABLE easter_eggs (username TEXT NOT NULL REFERENCES users(username), code TEXT NOT NULL, used_at INTEGER NOT NULL, PRIMARY KEY(username, code)); `); - if (old) { + if (old) { // Fails and rolls back rather than silently rounding or losing invalid balances. await tx.exec(`INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; INSERT INTO ledger(command_id,username,delta,reason) SELECT 'schema-v1:' || username, username, money, 'Opening balance migration' FROM users; DROP TABLE legacy_users;`); + } + await tx.exec('PRAGMA user_version = 4'); + } else if (version === 1) { + await tx.exec('CREATE TABLE easter_eggs (username TEXT NOT NULL REFERENCES users(username), code TEXT NOT NULL, used_at INTEGER NOT NULL, PRIMARY KEY(username, code)); PRAGMA user_version = 4'); + } else if (version === 2) { + const broken = await tx.all("SELECT username, delta FROM ledger WHERE reason = 'Easter egg: KRISHD' AND delta < 0"); + for (const entry of broken) { + const letters = [...entry.username].filter(character => /[a-z]/i.test(character)).length; + const correction = -entry.delta * letters; + if (!Number.isSafeInteger(correction)) throw new Error('Invalid KRISHD repair amount'); + await tx.run('UPDATE users SET money = money + ? WHERE username = ?', correction, entry.username); + await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', `repair:KRISHD:${entry.username}`, entry.username, correction, 'KRISHD multiplier repair'); + } + await tx.exec("UPDATE easter_eggs SET code = 'FRETUX' WHERE code = 'FRETUKS'; PRAGMA user_version = 4"); + } else if (version === 3) { + await tx.exec("UPDATE easter_eggs SET code = 'FRETUX' WHERE code = 'FRETUKS'; PRAGMA user_version = 4"); } - await tx.exec('PRAGMA user_version = 1'); }); } catch (error) { await release(); await db.close(); throw error; } const read = work => queue.run(['database'], () => work(db)); @@ -65,6 +81,26 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) catch (error) { if (error.code === 'SQLITE_CONSTRAINT') throw new AppError('USERNAME_EXISTS', 'Username already exists', 409); throw error; } }, setSession: (username, sessionHash) => transaction(tx => tx.run('UPDATE users SET session_hash = ? WHERE username = ?', sessionHash, username)), + async redeemEasterEgg(username, code) { + const repeatable = code === 'LOSERNOOB'; + return transaction(async tx => { + const user = await tx.get('SELECT money FROM users WHERE username = ?', username); + if (!user) throw new AppError('USER_NOT_FOUND', 'Account no longer exists', 404); + if (!repeatable && await tx.get('SELECT 1 FROM easter_eggs WHERE username = ? AND code = ?', username, code)) { + throw new AppError('EASTER_EGG_USED', 'This terminal command was already used.', 409); + } + const multiplier = code === 'FRETUX' ? 2 : code === 'KRISHD' ? [...username].filter(character => /[a-z]/i.test(character)).length : 1; + const next = code === 'ILIKEMONEY' ? user.money + 1000 : user.money * multiplier; + if (!Number.isSafeInteger(next) || next < 0) throw new AppError('BALANCE_LIMIT', 'This command would exceed the chip limit.', 409); + const delta = next - user.money; + if (!repeatable) await tx.run('INSERT INTO easter_eggs(username,code,used_at) VALUES(?,?,?)', username, code, Date.now()); + if (delta) { + await tx.run('UPDATE users SET money = ? WHERE username = ?', next, username); + await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', `easter:${code}:${username}`, username, delta, `Easter egg: ${code}`); + } + return {balance: next, effect: repeatable ? 'matrix' : 'credits'}; + }); + }, async recover() { return transaction(async tx => { const rows = await tx.all('SELECT id,state FROM lobbies'); diff --git a/public/frontend_home.js b/public/frontend_home.js index dd7aebe..a875ff9 100644 --- a/public/frontend_home.js +++ b/public/frontend_home.js @@ -2,7 +2,84 @@ const byId = id => document.getElementById(id); const client = new RealtimeClient(); let username; let lobbyId; + const easterTitle = byId('easter-title'); + let easterStage = 0; + let easterSequenceTimer = null; + let easterOpen = false; + let easterPending = false; + const secretCue = document.createElement('div'); + secretCue.id = 'secret-cue'; + secretCue.className = 'secret-cue'; + secretCue.setAttribute('aria-hidden', 'true'); + document.body.appendChild(secretCue); + const secretFlash = document.createElement('div'); + secretFlash.id = 'secret-flash'; + secretFlash.className = 'secret-flash'; + secretFlash.setAttribute('aria-hidden', 'true'); + document.body.appendChild(secretFlash); const error = message => {byId('join-error').textContent = message; byId('join-error').classList.toggle('hidden', !message);}; + const matrixColor = () => `hsl(${Math.floor(Math.random() * 360)} 100% 62%)`; + const unlockEasterTitle = () => { + easterOpen = true; + easterTitle.contentEditable = 'true'; + easterTitle.classList.add('easter-open'); + easterTitle.focus(); + document.getSelection()?.selectAllChildren(easterTitle); + }; + const cueSecret = (letter, strength = 'soft') => { + document.body.classList.remove('secret-shake', 'secret-shake-strong'); + void document.body.offsetWidth; + document.body.classList.add(strength === 'soft' ? 'secret-shake' : 'secret-shake-strong'); + if (!letter) return; + secretCue.textContent = letter; + secretCue.className = 'secret-cue'; + void secretCue.offsetWidth; + secretCue.className = `secret-cue secret-cue-${strength}`; + }; + const flashSecret = () => { + cueSecret('K', 'strong'); + document.body.classList.remove('secret-shake-strong'); + void document.body.offsetWidth; + document.body.classList.add('secret-shake-strong'); + secretFlash.classList.remove('secret-flash-active'); + void secretFlash.offsetWidth; + secretFlash.classList.add('secret-flash-active'); + }; + const armEasterSequence = (resetStage = true) => { + if (resetStage) easterStage = 0; + clearTimeout(easterSequenceTimer); + easterSequenceTimer = setTimeout(() => {easterStage = 0;}, 10000); + }; + const redeemEasterTitle = async () => { + if (easterPending) return; + const code = easterTitle.textContent.trim().toUpperCase(); + easterTitle.textContent = 'READY TO DEAL'; + easterTitle.contentEditable = 'false'; + easterTitle.classList.remove('easter-open'); + easterOpen = false; + if (!['ILIKEMONEY', 'FRETUX', 'KRISHD', 'LOSERNOOB'].includes(code)) return; + easterPending = true; + try { + const result = await apiRequest('/easter-egg', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({code})}); + if (result.effect === 'matrix') { + document.documentElement.style.setProperty('--matrix-rain', matrixColor()); + error('Matrix stream randomized.'); + } else { + byId('balance').textContent = result.balance; + error('Terminal command accepted.'); + } + } catch (problem) {error(problem.message);} + finally {easterPending = false;} + }; + document.addEventListener('keydown', event => { + if (easterOpen || event.repeat) return; + if (event.code === 'AltLeft' || event.code === 'AltRight') {armEasterSequence(); cueSecret('', 'soft'); return;} + if (easterStage === 0 && event.code === 'KeyJ') {easterStage = 1; armEasterSequence(false); cueSecret('J', 'medium'); return;} + if (easterStage === 1 && event.code === 'KeyF') {easterStage = 2; armEasterSequence(false); cueSecret('F', 'strong'); return;} + if (easterStage === 2 && event.code === 'KeyK') {event.preventDefault(); clearTimeout(easterSequenceTimer); flashSecret(); unlockEasterTitle(); easterStage = 0; return;} + if (easterStage) armEasterSequence(); + }); + easterTitle.addEventListener('keydown', event => { if (event.key === 'Enter') {event.preventDefault(); redeemEasterTitle();} }); const send = async message => {try {error(''); await client.send(message);} catch (problem) {error(problem.message);}}; const hideLobby = () => { lobbyId = null; localStorage.removeItem('activeLobbyId'); byId('lobby-room').classList.add('hidden'); diff --git a/public/home.html b/public/home.html index afaef39..d99d75b 100644 --- a/public/home.html +++ b/public/home.html @@ -10,23 +10,28 @@ -
-

🏆 High Rollers

+
+ GAMBLING API +
TABLE NETWORK ONLINE
+ +
+
+
- - - -
- Balance: $0 -
-
- - -
+ +
+

Operator console

+

READY TO DEAL_

+
AVAILABLE CREDITS$0
+
+ + +
+
+ diff --git a/public/login.html b/public/login.html index 09ae409..ae28d3b 100644 --- a/public/login.html +++ b/public/login.html @@ -9,19 +9,31 @@ -

Login / Register

-
- - - - - Passwords support up to 72 UTF-8 bytes. -
- - -
- -
+
+
+

secure terminal // v1.0

+

GAMBLING
API

+

Multiplayer poker, virtual chips, and a live table connection.

+ +
+
+
AUTHENTICATE● ● ●
+

Access your account

+

Sign in to return to your table, or register a new operator account.

+
+ + + + + Password limit: 72 UTF-8 bytes. +
+ + +
+ +
+
+
diff --git a/public/matrix.js b/public/matrix.js index 932834c..7a38016 100644 --- a/public/matrix.js +++ b/public/matrix.js @@ -25,7 +25,7 @@ function resizeCanvas() { function drawMatrix() { ctx.fillStyle = "rgba(0, 0, 0, 0.2)"; ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); - ctx.fillStyle = "#39FF14"; + ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--matrix-rain").trim() || "#39FF14"; ctx.font = fontSize + "px monospace"; for (let i = 0; i < drops.length; i++) { diff --git a/public/styles_home.css b/public/styles_home.css index 7a98880..8bf5638 100644 --- a/public/styles_home.css +++ b/public/styles_home.css @@ -1,336 +1,6 @@ -body { - background: radial-gradient(circle at top, #111, #000); - color: #0f0; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - margin: 0; - min-height: 100vh; - position: relative; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -body::before { - content: "♠ ♥ Welcome to Gambling API ♦ ♣"; - font-size: 24px; - color: #0f0; - margin-bottom: 40px; - display: block; - text-shadow: 0 0 5px #0f0, 0 0 10px #0f0; -} - -button { - background-color: #0f0; - color: #000; - border: none; - padding: 15px 30px; - font-size: 16px; - font-weight: bold; - border-radius: 12px; - cursor: pointer; - box-shadow: 0 0 10px #0f0, 0 0 5px #0f0 inset; - transition: background 0.2s ease-in-out, transform 0.2s; -} - -button:hover { - background-color: #1f1; - transform: scale(1.05); -} - -button.logout { - position: absolute; - top: 20px; - right: 20px; - padding: 10px 20px; - font-size: 14px; -} - -.balance { - margin-top: 20px; - color: #0f0; - text-shadow: 0 0 5px #0f0, 0 0 10px #0f0; -} - -.lobby-buttons { - display: flex; - gap: 20px; - margin-top: 30px; -} - -.host-form { - display: grid; - gap: 12px; - justify-items: center; - margin-top: 18px; -} - -.lobby-visibility { - color: #fff; - font-family: monospace; - display: flex; - align-items: center; - gap: 8px; - text-shadow: 0 0 3px #0f0; -} - -.lobby-visibility label { - border: 1px solid rgba(0, 255, 0, 0.5); - border-radius: 8px; - padding: 8px 12px; - background: rgba(0, 0, 0, 0.65); - cursor: pointer; -} - -.lobby-visibility input { - width: 16px; - height: 16px; - accent-color: #0f0; - vertical-align: middle; -} - -.lobby-room h2 { - color: #0f0; - font-size: 20px; - margin-bottom: 20px; - text-shadow: 0 0 5px #0f0; -} - -#player-list { - color: #fff; -} - -.join-browser { - margin-top: 20px; - width: min(520px, calc(100vw - 32px)); - background: rgba(0, 0, 0, 0.78); - border: 2px solid #0f0; - border-radius: 10px; - box-shadow: 0 0 10px #0f0; - padding: 18px; - box-sizing: border-box; -} - -.join-code-row { - display: flex; - gap: 10px; -} - -#join-form:not(.hidden) { - display: block; -} - -#join-form input { - padding: 10px; - border-radius: 8px; - border: 2px solid #0f0; - background-color: #111; - color: #0f0; - font-size: 16px; - box-shadow: 0 0 5px #0f0 inset; - width: 200px; -} - -.browser-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 16px; - border-top: 1px solid rgba(0, 255, 0, 0.35); - padding-top: 14px; -} - -.browser-header h2 { - margin: 0; - font-size: 18px; - color: #0f0; - font-family: monospace; -} - -.browser-header button, -.public-lobby button { - padding: 8px 14px; - font-size: 13px; - border-radius: 8px; -} - -.public-lobbies { - display: grid; - gap: 10px; - margin-top: 12px; - max-height: 220px; - overflow: auto; -} - -.public-lobbies p { - margin: 0; - color: #fff; - font-family: monospace; -} - -.public-lobby { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 12px; - border: 1px solid rgba(0, 255, 0, 0.45); - border-radius: 8px; - padding: 10px; - background: rgba(0, 255, 0, 0.06); -} - -.public-lobby-info { - display: grid; - gap: 4px; - color: #fff; - font-family: monospace; - min-width: 0; -} - -.public-lobby-info strong { - color: #0f0; - overflow-wrap: anywhere; -} - -.public-lobby-info span { - font-size: 13px; - color: #d8ffd8; -} - -#join-form button { - background-color: #0f0; - color: #000; - font-weight: bold; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; - transition: 0.2s; -} - -.error-message { - color: red; - margin-top: 10px; - font-weight: bold; - text-shadow: 0 0 3px red; -} - -#matrix-canvas { - position: fixed; - top: 0; - left: 0; - z-index: -1; - width: 100vw; - height: 100vh; - background: transparent; - pointer-events: none; -} - -.leaderboard { - position: absolute; - top: 100px; - left: 20px; - background: rgba(0, 0, 0, 0.7); - border: 2px solid #0f0; - border-radius: 10px; - padding: 20px; - width: 200px; - color: #0f0; - font-family: monospace; - box-shadow: 0 0 10px #0f0; - z-index: 1; -} - -.leaderboard h3 { - margin-top: 0; - font-size: 18px; - text-align: center; - text-shadow: 0 0 5px #0f0; -} - -.leaderboard ul { - list-style: none; - padding: 0; - margin: 0; -} - -.leaderboard li { - margin: 5px 0; - font-size: 14px; - text-shadow: 0 0 3px #0f0; -} - -.leaderboard li.you { - font-weight: bold; - color: #ff0; - text-shadow: 0 0 5px #ff0; -} - -.your-rank { - margin-top: 10px; - font-size: 14px; - color: #fff; - text-align: center; - text-shadow: 0 0 3px #0f0; -} - -.lobby-room { - position: relative; - background: rgba(0, 0, 0, 0.7); - border: 2px solid #0f0; - border-radius: 12px; - padding: 30px; - margin-top: 40px; - width: 400px; - text-align: center; - box-shadow: 0 0 10px #0f0; -} - -.lobby-header { - position: absolute; - top: 10px; - right: 10px; -} - -.leave-button { - background-color: #f00; - color: #fff; - font-size: 12px; - padding: 8px 12px; - border: none; - border-radius: 8px; - box-shadow: 0 0 5px red; - cursor: pointer; - transition: background 0.2s, transform 0.2s; -} - -.leave-button:hover { - background-color: #c00; - transform: scale(1.05); -} - -.start-game { - margin-top: 20px; - width: 100%; -} - -.start-game:disabled { - background-color: #163016; - color: #67a667; - cursor: not-allowed; - box-shadow: 0 0 5px #0f0 inset; - transform: none; -} - -.lobby-room { width: min(460px, calc(100vw - 32px)); overflow-wrap: anywhere; } -body { padding: 72px 16px 24px; gap: 8px; } -@media (max-width: 1000px) { - body { justify-content: flex-start; } - .leaderboard { position: static; width: min(460px, 100%); order: 10; margin-top: 24px; } - body::before { text-align: center; font-size: 20px; margin-bottom: 12px; } - .join-code-row { flex-wrap: wrap; } - #join-form input { width: 100%; min-width: 0; } - .lobby-visibility { flex-wrap: wrap; justify-content: center; } - .lobby-header { position: static; text-align: right; } -} +:root { --matrix-rain:#55f570; } body { min-height:100vh; margin:0; padding:0 24px; color:#e6ffe9; background:#040604; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; } #matrix-canvas { position:fixed; inset:0; z-index:-1; width:100%; height:100%; opacity:.15; pointer-events:none; }.app-header { width:min(1120px,100%); min-height:72px; margin:auto; display:flex; align-items:center; justify-content:space-between; gap:18px; border-bottom:1px solid #1c5727; }.wordmark { color:#f1fff2; text-decoration:none; font-weight:800; letter-spacing:-.06em; }.wordmark span { color:#56ed70; }.header-status { color:#7fc889; font-size:11px; letter-spacing:.06em; }.status-dot { display:inline-block; width:7px; height:7px; margin-right:5px; border-radius:50%; background:#54f570; box-shadow:0 0 9px #54f570; }.logout,.leave-button { border:1px solid #477d4f; background:transparent; color:#a3e9ac; padding:8px 11px; font:12px inherit; cursor:pointer; } +.menu-shell { width:min(1120px,100%); min-height:calc(100vh - 72px); margin:auto; display:grid; grid-template-columns:260px minmax(0,1fr); gap:clamp(30px,9vw,120px); align-items:center; }.leaderboard { background:#080d08; border:1px solid #2b7036; padding:20px; box-shadow:0 0 22px rgba(35,203,62,.1); }.panel-label { margin:0; color:#63c870; font-size:10px; letter-spacing:.13em; text-transform:uppercase; }.leaderboard h3 { margin:8px 0 16px; color:#8aff9a; font-size:17px; }.leaderboard ul { list-style:none; padding:0; margin:0; border-top:1px solid #1b4822; }.leaderboard li { padding:10px 0; border-bottom:1px solid #1b4822; color:#c4e6c8; font-size:13px; }.leaderboard li.you { color:#ffdc72; }.your-rank { margin-top:14px; color:#72d680; font-size:12px; } +.command-center { max-width:540px; }.command-center h1 { margin:12px 0 28px; color:#effff1; font-size:clamp(36px,6vw,62px); letter-spacing:-.08em; }.easter-open { color:#8aff9c; outline:1px dashed #55f570; outline-offset:8px; }.cursor { color:#55f570; animation:blink 1s step-end infinite; } .balance { display:grid; gap:6px; padding:17px 0; border-top:1px solid #326d3b; border-bottom:1px solid #326d3b; }.balance>span { color:#80be87; font-size:11px; letter-spacing:.12em; }.balance strong { color:#70f887; font-size:34px; letter-spacing:-.05em; }.lobby-buttons { display:flex; gap:10px; margin:24px 0; } +.secret-cue { position:fixed; z-index:21; top:50%; left:50%; color:#8aff9c; font:700 0/1 ui-monospace,SFMono-Regular,Consolas,monospace; text-shadow:0 0 24px #55f570; transform:translate(-50%,-50%); pointer-events:none; }.secret-cue-medium { font-size:clamp(90px,20vw,200px); animation:secret-letter 1s ease-out forwards; }.secret-cue-strong { font-size:clamp(150px,31vw,320px); animation:secret-letter 1s ease-out forwards; }.secret-flash { position:fixed; z-index:20; inset:0; pointer-events:none; opacity:0; background:rgba(122,255,145,.5); }.secret-flash-active { animation:secret-flash .28s ease-out forwards; }.secret-shake { animation:secret-shake .18s linear; }.secret-shake-strong { animation:secret-shake-big .38s linear; } @keyframes secret-letter { 0% { opacity:0; transform:translate(-50%,-50%) scale(.6); } 18% { opacity:1; } 100% { opacity:0; transform:translate(-50%,-50%) scale(1.1); } } @keyframes secret-flash { to { opacity:0; } } @keyframes secret-shake { 25% { transform:translateX(2px); } 50% { transform:translateX(-3px); } 75% { transform:translateX(2px); } } @keyframes secret-shake-big { 20% { transform:translateX(8px) rotate(.2deg); } 45% { transform:translateX(-10px) rotate(-.2deg); } 70% { transform:translateX(6px); } } @media (prefers-reduced-motion:reduce) { .secret-cue,.secret-shake,.secret-shake-strong,.secret-flash-active { animation:none; } } +button { min-height:44px; border:1px solid #52ee6b; background:#58ed70; color:#041005; padding:10px 16px; font:700 13px ui-monospace,SFMono-Regular,Consolas,monospace; cursor:pointer; letter-spacing:.04em; transition:transform .15s,box-shadow .15s,background .15s; } button:hover { transform:translateY(-2px); background:#8aff9c; box-shadow:0 0 16px rgba(82,242,106,.35); } button:disabled { opacity:.5; cursor:not-allowed; transform:none; }.secondary-action { background:#0b160c; color:#8aff9b; }.secondary-action:hover { background:#112914; }.host-form,.join-browser,.lobby-room { background:#080d08; border:1px solid #2d7437; padding:18px; box-shadow:0 0 20px rgba(32,187,56,.1); }.host-form { display:grid; gap:14px; }.lobby-visibility { display:flex; gap:10px; flex-wrap:wrap; color:#c7e5ca; font-size:12px; }.lobby-visibility label { border:1px solid #2d7437; padding:9px; cursor:pointer; }.lobby-visibility input { accent-color:#55f570; }.join-browser { display:grid; gap:16px; }.join-code-row { display:flex; gap:8px; }.join-code-row input { flex:1; min-width:0; background:#040604; border:1px solid #367d41; color:#dfffff; padding:11px; font:14px inherit; }.browser-header { display:flex; align-items:center; justify-content:space-between; border-top:1px solid #214d28; padding-top:14px; }.browser-header h2 { margin:0; color:#8aff9a; font-size:15px; }.public-lobbies { display:grid; gap:7px; }.public-lobby { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px; border:1px solid #234e2b; color:#c4e6c8; font-size:12px; }.public-lobby button { min-height:34px; padding:6px 10px; }.error-message { margin-top:12px; color:#ff7986; font-size:12px; }.lobby-room { margin-top:12px; }.lobby-header { display:flex; justify-content:flex-end; }.lobby-room h2 { color:#82f793; font-size:15px; overflow-wrap:anywhere; }.lobby-room p { color:#c6e9ca; font-size:13px; }.start-game { width:100%; margin-top:10px; } @keyframes blink { 50% { opacity:0; } } +@media(max-width:760px) { body { padding:0 16px; }.app-header { min-height:62px; }.header-status { display:none; }.menu-shell { min-height:0; padding:44px 0; grid-template-columns:1fr; gap:28px; }.leaderboard { order:2; }.command-center { max-width:none; }.command-center h1 { font-size:44px; }.lobby-buttons { flex-direction:column; }.lobby-buttons button { width:100%; }.join-code-row { flex-wrap:wrap; }.join-code-row button { width:100%; } } diff --git a/public/styles_login.css b/public/styles_login.css index 3b29664..05cd91d 100644 --- a/public/styles_login.css +++ b/public/styles_login.css @@ -1,87 +1,7 @@ -body { - background: radial-gradient(circle at top, #111, #000); - color: #fff; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - min-height: 100vh; - margin: 0; -} - -/* Add styling for the heading */ -h1 { - font-size: 28px; - color: #0f0; - margin-bottom: 20px; - text-shadow: 0 0 5px #0f0, 0 0 10px #0f0; -} - -/* Your form stays centered below the heading */ -form { - background: rgba(0, 0, 0, 0.85); - border: 2px solid #0f0; - border-radius: 12px; - padding: 40px 30px; - box-shadow: 0 0 15px rgba(0, 255, 0, 0.3), 0 0 5px rgba(255, 255, 255, 0.1); - display: flex; - flex-direction: column; - gap: 20px; - width: 300px; -} - -input { - padding: 12px 15px; - border: none; - border-radius: 8px; - font-size: 16px; - background: #222; - color: white; - outline: none; - box-shadow: inset 0 0 5px #0f0; - transition: 0.2s ease-in-out; -} - -input:focus { - box-shadow: inset 0 0 10px #0f0, 0 0 5px #0f0; -} - -button { - padding: 12px; - background: #06d429; - color: #000; - border: none; - border-radius: 8px; - font-weight: bold; - cursor: pointer; - transition: background 0.2s ease-in-out; - font-size: 16px; -} - -button:hover { - background: #1f1; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.auth-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.auth-error { - color: #ff6b6b; - font-size: 14px; - line-height: 1.4; - margin: 0; - text-align: center; -} - - -form { width: min(364px, calc(100vw - 32px)); } -body { padding: 24px 0; } +body { min-height:100vh; margin:0; color:#e4ffe7; background:radial-gradient(circle at 18% 15%,#143c1d 0,#070a07 32%,#020302 75%); font-family:ui-monospace,SFMono-Regular,Consolas,monospace; } +.auth-shell { width:min(1000px,calc(100% - 32px)); min-height:100vh; margin:auto; display:grid; grid-template-columns:1fr 430px; align-items:center; gap:70px; } +.eyebrow { margin:0; color:#6edc7b; font-size:11px; letter-spacing:.14em; text-transform:uppercase; }.auth-intro h1 { margin:14px 0; color:#effff0; font:700 clamp(48px,8vw,82px)/.88 ui-monospace,SFMono-Regular,Consolas,monospace; letter-spacing:-.08em; text-shadow:0 0 28px rgba(63,255,91,.32); }.auth-intro h1 span { color:#47f568; }.intro-copy { max-width:370px; color:#b9d8bc; font:16px/1.6 system-ui,sans-serif; }.intro-status { padding:20px 0 0; margin:25px 0 0; border-top:1px solid #245a2c; list-style:none; color:#80d98b; font-size:12px; line-height:2; } +.auth-panel { background:#090d09; border:1px solid #3de65b; box-shadow:0 22px 70px rgba(0,0,0,.55),0 0 24px rgba(22,208,58,.13); }.panel-chrome { display:flex; justify-content:space-between; padding:11px 14px; border-bottom:1px solid #245a2c; color:#75df83; font-size:11px; letter-spacing:.1em; }.auth-panel h2 { margin:30px 30px 8px; font-size:25px; letter-spacing:-.04em; }.panel-copy { margin:0 30px 25px; color:#9bb69e; font:14px/1.5 system-ui,sans-serif; } +form { display:grid; gap:9px; padding:0 30px 30px; } label { color:#9ee8a7; font-size:12px; letter-spacing:.06em; } input { min-width:0; padding:13px 14px; border:1px solid #2d6b35; border-radius:0; outline:0; background:#050705; color:#effff0; font:15px ui-monospace,SFMono-Regular,Consolas,monospace; } input:focus { border-color:#54fb6e; box-shadow:0 0 0 3px rgba(72,245,104,.16); } small { color:#719c76; font-size:11px; } +button { min-height:44px; border:1px solid #53ed6c; border-radius:0; background:#57ed70; color:#041005; font:700 13px ui-monospace,SFMono-Regular,Consolas,monospace; cursor:pointer; letter-spacing:.04em; transition:transform .15s,box-shadow .15s,background .15s; } button:hover { transform:translateY(-2px); box-shadow:0 0 16px rgba(82,242,106,.42); background:#8aff9c; } button:disabled { opacity:.55; cursor:not-allowed; transform:none; }.auth-actions { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:12px; }.secondary-action { background:#0b160c; color:#8aff9b; }.secondary-action:hover { background:#112914; }.auth-error { margin:4px 0 0; color:#ff7885; font:12px/1.4 system-ui,sans-serif; } +@media(max-width:720px) { .auth-shell { min-height:unset; padding:54px 0; grid-template-columns:1fr; gap:32px; }.auth-intro h1 { font-size:54px; }.auth-panel h2,.panel-copy { margin-left:22px; margin-right:22px; } form { padding-left:22px; padding-right:22px; } } diff --git a/public/styles_poker.css b/public/styles_poker.css index 2b2471a..8d4fe83 100644 --- a/public/styles_poker.css +++ b/public/styles_poker.css @@ -400,6 +400,15 @@ button:disabled:hover { } } +/* Terminal table overhaul */ +body { background:radial-gradient(ellipse at 50% 45%,#0e2a14 0,#060806 43%,#010201 100%); font-family:ui-monospace,SFMono-Regular,Consolas,monospace; color:#dfffff; } +.poker-table { width:min(1180px,100%); min-height:100vh; margin:auto; padding:18px 28px 24px; grid-template-rows:auto 1fr auto auto; gap:18px; } +.table-header { grid-template-columns:1fr auto 1fr; padding-bottom:14px; border-bottom:1px solid #215a2a; }.round-panel { border:0; border-left:3px solid #55f570; box-shadow:none; background:transparent; padding:6px 12px; color:#dfffff; font-size:15px; text-shadow:none; }.round-panel strong { color:#74ff89; }.divider { color:#367c40; }.leave-table { border:1px solid #467e4e; border-radius:0; background:transparent; box-shadow:none; color:#a3e9ac; padding:8px 12px; }.leave-table:hover { background:#102414; box-shadow:none; transform:none; } +.community-area { width:min(760px,100%); align-self:center; gap:14px; }.turn-panel { border:1px solid #3ce956; box-shadow:0 0 26px rgba(42,235,73,.18); background:#070d08; }.turn-queue li { border-color:#275f30; box-shadow:none; background:#080d08; color:#bfe6c3; }.turn-queue li.active { background:#56ed70; color:#041005; box-shadow:0 0 13px rgba(82,242,106,.5); }.turn-queue li.next { border-color:#8bcf94; } +.card-row { gap:10px; }.card { border-radius:0; border-color:#60ee78; background:#e9ffee; box-shadow:0 0 14px rgba(64,240,94,.28); }.card.placeholder { background:#071008; color:#63db74; box-shadow:none; }.actions { padding:12px; border-top:1px solid #215a2a; border-bottom:1px solid #215a2a; }.actions button,.confirm-bet { min-width:112px; border:1px solid #53ed6c; border-radius:0; background:#0b180d; color:#81fb93; box-shadow:none; font-family:inherit; }.actions button:hover,.confirm-bet:hover { background:#59ed70; color:#041005; box-shadow:0 0 16px rgba(82,242,106,.35); transform:translateY(-2px); }.actions button[data-action="fold"] { border-color:#a64d59; color:#ff8996; }.actions button[data-action="fold"]:hover { background:#601c26; color:#fff; box-shadow:0 0 16px rgba(255,65,87,.3); } +.player-area { width:min(760px,100%); padding:8px 0 0; border-top:1px solid #215a2a; }.hand { gap:13px; }.player-status { width:100%; gap:12px 22px; border:1px solid #286532; box-shadow:none; background:#070c08; color:#cfead2; padding:12px; font-size:12px; }.player-status span:first-child { color:#77fb8b; font-weight:700; }.spectator-note,.showdown-panel { border-color:#3de65b; border-radius:0; box-shadow:0 0 20px rgba(40,230,70,.18); background:#070c08; }.showdown-panel { right:28px; bottom:28px; }.bet-modal { background:rgba(0,0,0,.7); }.bet-panel { border-color:#4fec69; box-shadow:0 0 38px rgba(52,235,81,.32); background:#070b08; border-radius:0; }.close-bet { border-radius:0; }.bet-amount span { border-color:#4eeb68; background:#050805; }.bet-amount button { min-width:34px; } +@media (max-width:700px) { .poker-table { padding:14px; gap:14px; }.table-header { gap:10px; }.round-panel { border-left:0; border-bottom:2px solid #55f570; }.actions { width:100%; }.actions button { flex:1; min-width:90px; }.player-status { justify-content:flex-start; }.showdown-panel { right:14px; bottom:14px; } } + .poker-table { gap: 24px; } .turn-panel { min-width: 0; width: min(460px, 100%); } .community-area, .player-area { max-width: 100%; min-width: 0; } diff --git a/test/server.test.js b/test/server.test.js index d5e6cab..b03e818 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -70,6 +70,17 @@ test('authentication is rate limited and cookies tolerate invalid escapes', asyn assert.equal(limited.status, 429); assert.ok(limited.headers.has('retry-after')); assert.equal(parseCookies('bad=%ZZ; authorization=ok').authorization, 'ok'); }); +test('easter egg credits are server-authoritative and single-use except matrix color', async t => { + const {request} = await fixture(t); + const {token} = await (await request('/register', {method: 'POST', body: {username: 'agent42', password: 'password'}})).json(); + const redeem = code => request('/easter-egg', {method: 'POST', token, body: {code}}); + assert.deepEqual(await (await redeem('ILIKEMONEY')).json(), {balance: 1200, effect: 'credits'}); + assert.equal((await redeem('ILIKEMONEY')).status, 409); + assert.deepEqual(await (await redeem('FRETUX')).json(), {balance: 2400, effect: 'credits'}); + assert.deepEqual(await (await redeem('KRISHD')).json(), {balance: 12000, effect: 'credits'}); + assert.equal((await redeem('LOSERNOOB')).status, 200); + assert.equal((await redeem('LOSERNOOB')).status, 200); +}); test('socket expires without waiting for a client message', async t => { const {api, base} = await fixture(t, {sessionSeconds: 1}); const {token} = await api.sessions.register({username: 'alice', password: 'password'});