This commit is contained in:
78
lib/auth.ts
Normal file
78
lib/auth.ts
Normal 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
65
lib/bingo-data.ts
Normal 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
64
lib/db/index.ts
Normal 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
48
lib/db/schema.ts
Normal 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
134
lib/sounds.ts
Normal 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
6
lib/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user