V1 bingo
Some checks failed
Deploy / build-and-deploy (push) Failing after 2m53s

This commit is contained in:
2026-06-14 21:29:43 +03:00
commit 05677924b5
55 changed files with 10816 additions and 0 deletions

78
lib/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import { v4 as uuidv4 } from "uuid";
import bcrypt from "bcryptjs";
import { db } from "./db";
import { users, sessions } from "./db/schema";
import { eq, and } from "drizzle-orm";
import { cookies } from "next/headers";
const SESSION_COOKIE = "barabingo_session";
const SESSION_DAYS = 7;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
const ADMIN_NICKNAMES = ["admin"];
export async function registerUser(nickname: string, password: string) {
const existing = db.select().from(users).where(eq(users.nickname, nickname)).get();
if (existing) return { error: "Nickname taken" };
const userCount = db.select().from(users).all().length;
const nicknameLower = nickname.toLowerCase();
const firstUser = userCount === 0;
const isAdmin = firstUser || ADMIN_NICKNAMES.includes(nicknameLower);
const id = uuidv4();
const passwordHash = await hashPassword(password);
const createdAt = new Date().toISOString();
db.insert(users).values({ id, nickname, passwordHash, isAdmin, createdAt }).run();
return { id, nickname, isAdmin };
}
export async function loginUser(nickname: string, password: string) {
const user = db.select().from(users).where(eq(users.nickname, nickname)).get();
if (!user) return { error: "User not found" };
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) return { error: "Wrong password" };
const sessionId = uuidv4();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000).toISOString();
const createdAt = new Date().toISOString();
db.insert(sessions).values({ id: sessionId, userId: user.id, expiresAt, createdAt }).run();
return { sessionId, user: { id: user.id, nickname: user.nickname, isAdmin: user.isAdmin } };
}
export async function getSession(token?: string) {
if (!token) return null;
const session = db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) return null;
if (new Date(session.expiresAt) < new Date()) {
db.delete(sessions).where(eq(sessions.id, token)).run();
return null;
}
const user = db.select().from(users).where(eq(users.id, session.userId)).get();
if (!user) return null;
return { id: user.id, nickname: user.nickname, isAdmin: user.isAdmin };
}
export async function deleteSession(token: string) {
db.delete(sessions).where(eq(sessions.id, token)).run();
}
export async function getServerSession() {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
return getSession(token);
}
export { SESSION_COOKIE };

65
lib/bingo-data.ts Normal file
View File

@@ -0,0 +1,65 @@
export type BingoEntry = {
text: string;
emoji: string;
soundCategory: string;
};
const FUNNY_ITEMS: BingoEntry[] = [
{ text: "Капитан затопил весь суб", emoji: "🌊", soundCategory: "flood" },
{ text: "Реактор взорвался", emoji: "💥", soundCategory: "explosion" },
{ text: "Вечеринка заразы", emoji: "🧟", soundCategory: "monster" },
{ text: "Клоун-убийца", emoji: "🤡", soundCategory: "horn" },
{ text: "Мудраптор в балласте", emoji: "🦖", soundCategory: "monster" },
{ text: "Уборщик всех спас", emoji: "🧹", soundCategory: "horn" },
{ text: "Доктор всех обколол морфием", emoji: "💉", soundCategory: "death" },
{ text: "Охранник застрелил капитана", emoji: "🔫", soundCategory: "explosion" },
{ text: "Все пьяны", emoji: "🍺", soundCategory: "horn" },
{ text: "Таламус пророс в суб", emoji: "🌿", soundCategory: "monster" },
{ text: "Червь съел суб целиком", emoji: "🐛", soundCategory: "monster" },
{ text: "Гудок в войсе", emoji: "📯", soundCategory: "horn" },
{ text: "Механик заварил себя в стену", emoji: "🔧", soundCategory: "horn" },
{ text: "Кислород кончился во время роя", emoji: "🫁", soundCategory: "alarm" },
{ text: "Капитан полный вперёд в стену", emoji: "🚀", soundCategory: "explosion" },
{ text: "Психоз и массовая паника", emoji: "🌀", soundCategory: "alarm" },
{ text: "Питомец-мудраптор сбесился", emoji: "🐊", soundCategory: "monster" },
{ text: "Ядерные стержни как оружие", emoji: "☢️", soundCategory: "alarm" },
{ text: "Уборщик моет радиоактивную воду", emoji: "☣️", soundCategory: "death" },
{ text: "Заражённый всё ещё работает", emoji: "😵", soundCategory: "death" },
{ text: "Рейлган дружественный огонь", emoji: "🎯", soundCategory: "explosion" },
{ text: "Балласт залит кислотой", emoji: "🧪", soundCategory: "alarm" },
{ text: "Доктор сварил химкоктейль", emoji: "🧬", soundCategory: "death" },
{ text: "Суб сплющило на 5000м", emoji: "🔱", soundCategory: "flood" },
{ text: "Последние слова капитана", emoji: "🗣️", soundCategory: "death" },
{ text: "Все умерли от одного мудраптора", emoji: "🦎", soundCategory: "monster" },
{ text: "Клоунский рожок как маяк", emoji: "🎺", soundCategory: "horn" },
{ text: "Охранник арестован за военные преступления", emoji: "⛓️", soundCategory: "horn" },
{ text: "Реактор в порядке (ложь)", emoji: "🤥", soundCategory: "alarm" },
{ text: "Ассистент починил корпус скотчем", emoji: "📦", soundCategory: "horn" },
{ text: "Капитан торгуется с монстром", emoji: "🤝", soundCategory: "horn" },
{ text: "Весь экипаж на гиперзине", emoji: "💊", soundCategory: "chaos" },
{ text: "Ассистент с ножом", emoji: "🔪", soundCategory: "death" },
{ text: "Скелет экипажа", emoji: "💀", soundCategory: "death" },
{ text: "FREE SPACE — Хаос", emoji: "🔥", soundCategory: "chaos" },
];
export const SOUND_CATEGORIES = [
{ value: "horn", label: "🎺 Клоунский рожок" },
{ value: "alarm", label: "🚨 Тревога" },
{ value: "flood", label: "🌊 Затопление" },
{ value: "explosion", label: "💥 Взрыв" },
{ value: "monster", label: "👹 Монстр" },
{ value: "death", label: "💀 Смерть" },
{ value: "chaos", label: "🔥 Хаос" },
{ value: "bingo", label: "🏆 Бинго" },
{ value: "custom", label: "📂 Свой звук" },
];
export const EMOJIS = [
"💀", "🔥", "🌊", "💥", "🤡", "🧟", "🦖", "🧹",
"💉", "🔫", "🍺", "🌿", "🐛", "📯", "🔧", "🫁",
"🚀", "🌀", "🐊", "☢️", "☣️", "😵", "🎯", "🧪",
"🧬", "🔱", "🗣️", "🦎", "🎺", "⛓️", "🤥", "📦",
"🤝", "💊", "🔪", "🎮", "🪠", "🦈", "🐙", "🧨",
"⚡", "🕳️", "🎭", "🪤", "🧲", "⚰️", "📡", "🛸",
"👽", "🤖", "👾", "🎵", "🚽", "🛏️", "🍕", "🧀",
];

64
lib/db/index.ts Normal file
View File

@@ -0,0 +1,64 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema";
import path from "path";
const DB_PATH = path.join(process.cwd(), "data", "bingo.db");
function getDb() {
const sqlite = new Database(DB_PATH);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
return sqlite;
}
function initDatabase(sqlite: Database.Database) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
nickname TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS campaigns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
grid_size INTEGER NOT NULL DEFAULT 5,
status TEXT NOT NULL DEFAULT 'active',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bingo_items (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
text TEXT NOT NULL,
emoji TEXT NOT NULL DEFAULT '💀',
sound_category TEXT NOT NULL DEFAULT 'horn',
sound_url TEXT,
grid_index INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS marks (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES bingo_items(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_item_pos ON bingo_items(campaign_id, grid_index);
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_mark ON marks(campaign_id, item_id);
`);
try { sqlite.exec("ALTER TABLE bingo_items ADD COLUMN sound_url TEXT"); } catch {}
}
const sqlite = getDb();
initDatabase(sqlite);
export const db = drizzle(sqlite, { schema });

48
lib/db/schema.ts Normal file
View File

@@ -0,0 +1,48 @@
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
nickname: text("nickname").unique().notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).default(false).notNull(),
createdAt: text("created_at").notNull(),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull(),
});
export const campaigns = sqliteTable("campaigns", {
id: text("id").primaryKey(),
name: text("name").notNull(),
gridSize: integer("grid_size").notNull().default(5),
status: text("status").notNull().default("active"),
createdBy: text("created_by").notNull().references(() => users.id),
createdAt: text("created_at").notNull(),
});
export const bingoItems = sqliteTable("bingo_items", {
id: text("id").primaryKey(),
campaignId: text("campaign_id").notNull().references(() => campaigns.id, { onDelete: "cascade" }),
text: text("text").notNull(),
emoji: text("emoji").notNull().default("💀"),
soundCategory: text("sound_category").notNull().default("horn"),
soundUrl: text("sound_url"),
gridIndex: integer("grid_index").notNull(),
createdAt: text("created_at").notNull(),
}, (table) => [
uniqueIndex("idx_campaign_item_pos").on(table.campaignId, table.gridIndex),
]);
export const marks = sqliteTable("marks", {
id: text("id").primaryKey(),
campaignId: text("campaign_id").notNull().references(() => campaigns.id, { onDelete: "cascade" }),
itemId: text("item_id").notNull().references(() => bingoItems.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: text("created_at").notNull(),
}, (table) => [
uniqueIndex("idx_unique_mark").on(table.campaignId, table.itemId),
]);

134
lib/sounds.ts Normal file
View File

@@ -0,0 +1,134 @@
let audioCtx: AudioContext | null = null;
function getCtx(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
}
return audioCtx;
}
type OscillatorConfig = {
freq: number;
type: OscillatorType;
duration: number;
gain?: number;
};
function playTone(config: OscillatorConfig) {
try {
const ctx = getCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = config.type;
osc.frequency.setValueAtTime(config.freq, ctx.currentTime);
gain.gain.setValueAtTime(config.gain ?? 0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + config.duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + config.duration);
} catch {
// Audio not supported
}
}
function playNoise(duration: number, gain = 0.15) {
try {
const ctx = getCtx();
const bufferSize = Math.floor(ctx.sampleRate * duration);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2);
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
source.connect(gainNode);
gainNode.connect(ctx.destination);
source.start();
} catch {
// Audio not supported
}
}
export function playHonk() {
playTone({ freq: 800, type: "square", duration: 0.08, gain: 0.2 });
setTimeout(() => playTone({ freq: 600, type: "square", duration: 0.12, gain: 0.2 }), 80);
}
export function playReactorAlarm() {
for (let i = 0; i < 4; i++) {
setTimeout(() => {
playTone({ freq: 440 + i * 110, type: "sawtooth", duration: 0.2, gain: 0.15 });
setTimeout(() => playTone({ freq: 330 + i * 80, type: "sawtooth", duration: 0.2, gain: 0.15 }), 100);
}, i * 300);
}
}
export function playHullBreach() {
playTone({ freq: 100, type: "sine", duration: 0.5, gain: 0.4 });
setTimeout(() => playTone({ freq: 80, type: "sine", duration: 0.5, gain: 0.3 }), 200);
playNoise(0.5, 0.1);
}
export function playExplosion() {
playNoise(0.6, 0.4);
playTone({ freq: 60, type: "sine", duration: 0.5, gain: 0.5 });
setTimeout(() => playTone({ freq: 40, type: "sine", duration: 0.3, gain: 0.3 }), 200);
}
export function playMonster() {
playTone({ freq: 80, type: "sawtooth", duration: 0.4, gain: 0.2 });
setTimeout(() => playTone({ freq: 60, type: "sawtooth", duration: 0.4, gain: 0.15 }), 200);
setTimeout(() => playTone({ freq: 50, type: "square", duration: 0.5, gain: 0.2 }), 400);
}
export function playDeath() {
playTone({ freq: 400, type: "sine", duration: 0.15, gain: 0.2 });
setTimeout(() => playTone({ freq: 300, type: "sine", duration: 0.15, gain: 0.2 }), 150);
setTimeout(() => playTone({ freq: 200, type: "sine", duration: 0.3, gain: 0.2 }), 300);
}
export function playBingo() {
const notes = [523, 659, 784, 1047];
notes.forEach((freq, i) => {
setTimeout(() => playTone({ freq, type: "square", duration: 0.2, gain: 0.2 }), i * 120);
});
setTimeout(() => playNoise(0.3, 0.1), 480);
}
export function playChaosRiser() {
const pitches = [200, 250, 300, 400, 500, 600, 800, 1000];
pitches.forEach((freq, i) => {
setTimeout(() => playTone({ freq, type: "sawtooth", duration: 0.1, gain: 0.08 }), i * 50);
});
}
export function playSoundUrl(url: string) {
try {
const audio = new Audio(url);
audio.volume = 0.4;
audio.play().catch(() => {});
} catch {}
}
export function playSound(category: string, soundUrl?: string | null) {
if (soundUrl) {
playSoundUrl(soundUrl);
return;
}
switch (category) {
case "horn": playHonk(); break;
case "alarm": playReactorAlarm(); break;
case "flood": playHullBreach(); break;
case "explosion": playExplosion(); break;
case "monster": playMonster(); break;
case "death": playDeath(); break;
case "bingo": playBingo(); break;
case "chaos": playChaosRiser(); break;
default: playHonk(); break;
}
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}