import { safeName } from '../content-policy.js'; export const name = 'flappy'; export const description = 'Play ASCII Flappy and save scores'; function grid(width, height) { return Array.from({ length: height }, () => Array(width).fill(' ')); } function frame(cells) { return `+${'-'.repeat(cells[0].length)}+\n${cells.map(row => `|${row.join('')}|`).join('\n')}\n+${'-'.repeat(cells[0].length)}+`; } export class FlappyGame { constructor(random = Math.random) { this.random = random; this.width = 30; this.height = 12; this.reset(); } reset() { this.y = 5; this.velocity = 0; this.score = 0; this.over = false; this.recorded = false; this.pipes = [{ x: 22, gap: this.newGap() }]; } newGap() { return 2 + Math.floor(this.random() * (this.height - 6)); } tick(flap = false) { if (this.over) return; this.velocity = flap ? -0.65 : Math.min(0.7, this.velocity + 0.16); this.y += this.velocity; for (const pipe of this.pipes) pipe.x--; if (this.pipes[0].x < -1) this.pipes.shift(); const last = this.pipes.at(-1); if (last.x < 17) this.pipes.push({ x: this.width - 1, gap: this.newGap() }); for (const pipe of this.pipes) { if (pipe.x === 4) this.score++; if ((pipe.x === 4 || pipe.x === 5) && (this.y < pipe.gap || this.y > pipe.gap + 3)) this.over = true; } if (this.y < 0 || this.y >= this.height) this.over = true; } render() { const cells = grid(this.width, this.height); for (const pipe of this.pipes) if (pipe.x >= 0 && pipe.x < this.width) for (let y = 0; y < this.height; y++) if (y < pipe.gap || y > pipe.gap + 3) cells[y][pipe.x] = '#'; const birdY = Math.round(this.y); if (birdY >= 0 && birdY < this.height) cells[birdY][4] = '>'; return `${frame(cells)}\nScore: ${this.score}${this.over ? ' GAME OVER' : ''}`; } } export class FlappyService { constructor(db) { this.db = db; this.live = new WeakMap(); } draw(ctx, text, help) { if (!ctx.channel?.destroyed) ctx.channel.write(`\x1b[?25l\x1b[2J\x1b[H${text}\r\n\r\n${help}`.replace(/\n/g, '\r\n')); } display(ctx, game) { const player = safeName(ctx.username); const scores = this.db.getLeaderboard('flappy', 3); const board = scores.length ? scores.map((row, index) => `${index + 1}. ${row.player.slice(0, 16).padEnd(16)} ${row.score}`).join('\n') : 'No scores yet'; return `${game.render()}\n\nYour high score: ${Math.max(game.score, this.db.getPlayerBest('flappy', player))}\nTop Flappy\n${board}`; } stop(ctx, message = '') { const state = this.live.get(ctx); if (state?.timer) clearInterval(state.timer); this.live.delete(ctx); ctx.tui?.setInputHandler(null); if (message) ctx.tui?.finishLive(`\x1b[?25h${message}`); } start(ctx) { if (!ctx.tui) return 'Flappy is interactive; connect with an SSH shell (without a remote command).'; this.stop(ctx); const game = new FlappyGame(); const state = { game, timer: null, pendingFlap: false }; this.live.set(ctx, state); const finish = () => { if (!game.recorded) { this.db.addScore('flappy', safeName(ctx.username), game.score); game.recorded = true; } if (state.timer) clearInterval(state.timer); state.timer = null; this.draw(ctx, this.display(ctx, game), 'R: restart Q: quit'); }; const update = flap => { game.tick(flap); if (game.over) finish(); else this.draw(ctx, this.display(ctx, game), 'SPACE: flap Q: quit'); }; ctx.tui.setInputHandler(input => { if (input.includes('\x03') || /q/i.test(input) || input.includes('\x1b')) this.stop(ctx, 'Flappy exited.'); else if (game.over && /r/i.test(input)) this.start(ctx); else if (input.includes(' ')) state.pendingFlap = true; return true; }); state.timer = setInterval(() => { update(state.pendingFlap); state.pendingFlap = false; }, 180); this.draw(ctx, this.display(ctx, game), 'SPACE: flap Q: quit'); return '__LIVE__'; } disconnect(ctx) { this.stop(ctx); } } export function run({ ctx, api }) { return api.flappy.start(ctx); }