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 @@
- - - - -Operator console
+secure terminal // v1.0
+Multiplayer poker, virtual chips, and a live table connection.
+Sign in to return to your table, or register a new operator account.
+ +