feat: trivia command!

This commit is contained in:
syntaxbullet
2026-01-11 14:37:17 +01:00
parent 1cd3dbcd72
commit 35bd1f58dd
10 changed files with 1348 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
import { triviaService } from "./trivia.service";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, userTimers } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { config } from "@shared/lib/config";
import { TimerType } from "@shared/lib/constants";
// Mock fetch for OpenTDB API
const mockFetch = mock(() => Promise.resolve({
json: () => Promise.resolve({
response_code: 0,
results: [{
category: Buffer.from('General Knowledge').toString('base64'),
type: 'multiple',
difficulty: Buffer.from('medium').toString('base64'),
question: Buffer.from('What is 2 + 2?').toString('base64'),
correct_answer: Buffer.from('4').toString('base64'),
incorrect_answers: [
Buffer.from('3').toString('base64'),
Buffer.from('5').toString('base64'),
Buffer.from('22').toString('base64'),
]
}]
})
}));
global.fetch = mockFetch as any;
describe("TriviaService", () => {
const TEST_USER_ID = "999999999";
const TEST_USERNAME = "testuser";
beforeEach(async () => {
// Clean up test data
await DrizzleClient.delete(userTimers)
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
// Ensure test user exists with sufficient balance
await DrizzleClient.insert(users)
.values({
id: BigInt(TEST_USER_ID),
username: TEST_USERNAME,
balance: 1000n,
xp: 0n,
})
.onConflictDoUpdate({
target: [users.id],
set: {
balance: 1000n,
}
});
});
afterEach(async () => {
// Clean up
await DrizzleClient.delete(userTimers)
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
});
describe("fetchQuestion", () => {
it("should fetch and decode a trivia question", async () => {
const question = await triviaService.fetchQuestion();
expect(question).toBeDefined();
expect(question.question).toBe('What is 2 + 2?');
expect(question.correctAnswer).toBe('4');
expect(question.incorrectAnswers).toHaveLength(3);
expect(question.type).toBe('multiple');
});
});
describe("canPlayTrivia", () => {
it("should allow playing when no cooldown exists", async () => {
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(true);
expect(result.nextAvailable).toBeUndefined();
});
it("should prevent playing when on cooldown", async () => {
const futureDate = new Date(Date.now() + 60000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: futureDate,
});
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(false);
expect(result.nextAvailable).toBeDefined();
});
it("should allow playing when cooldown has expired", async () => {
const pastDate = new Date(Date.now() - 1000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: pastDate,
});
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(true);
});
});
describe("startTrivia", () => {
it("should start a trivia session and deduct entry fee", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
expect(session).toBeDefined();
expect(session.sessionId).toContain(TEST_USER_ID);
expect(session.userId).toBe(TEST_USER_ID);
expect(session.question).toBeDefined();
expect(session.allAnswers).toHaveLength(4);
expect(session.entryFee).toBe(config.trivia.entryFee);
expect(session.potentialReward).toBeGreaterThan(0n);
// Verify balance deduction
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(1000n - config.trivia.entryFee);
// Verify cooldown was set
const cooldown = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(TEST_USER_ID)),
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
eq(userTimers.key, 'default')
)
});
expect(cooldown).toBeDefined();
});
it("should throw error if user has insufficient balance", async () => {
// Set balance to less than entry fee
await DrizzleClient.update(users)
.set({ balance: 10n })
.where(eq(users.id, BigInt(TEST_USER_ID)));
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
.rejects.toThrow('Insufficient funds');
});
it("should throw error if user is on cooldown", async () => {
const futureDate = new Date(Date.now() + 60000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: futureDate,
});
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
.rejects.toThrow('cooldown');
});
});
describe("submitAnswer", () => {
it("should award prize for correct answer", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const balanceBefore = (await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
}))!.balance!;
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
expect(result.correct).toBe(true);
expect(result.reward).toBe(session.potentialReward);
expect(result.correctAnswer).toBe(session.question.correctAnswer);
// Verify balance increase
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(balanceBefore + session.potentialReward);
});
it("should not award prize for incorrect answer", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const balanceBefore = (await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
}))!.balance!;
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, false);
expect(result.correct).toBe(false);
expect(result.reward).toBe(0n);
expect(result.correctAnswer).toBe(session.question.correctAnswer);
// Verify balance unchanged (already deducted at start)
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(balanceBefore);
});
it("should throw error if session doesn't exist", async () => {
await expect(triviaService.submitAnswer("invalid_session", TEST_USER_ID, true))
.rejects.toThrow('Session not found');
});
it("should prevent double submission", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
// Try to submit again
await expect(triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true))
.rejects.toThrow('Session not found');
});
});
describe("getSession", () => {
it("should retrieve active session", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const retrieved = triviaService.getSession(session.sessionId);
expect(retrieved).toBeDefined();
expect(retrieved?.sessionId).toBe(session.sessionId);
});
it("should return undefined for non-existent session", () => {
const retrieved = triviaService.getSession("invalid_session");
expect(retrieved).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,310 @@
import { users, userTimers, transactions } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { config } from "@shared/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
import { TimerType, TransactionType } from "@shared/lib/constants";
import { DrizzleClient } from "@shared/db/DrizzleClient";
// OpenTDB API Response Types
interface OpenTDBResponse {
response_code: number;
results: Array<{
category: string;
type: 'boolean' | 'multiple';
difficulty: string;
question: string;
correct_answer: string;
incorrect_answers: string[];
}>;
}
export interface TriviaQuestion {
question: string;
correctAnswer: string;
incorrectAnswers: string[];
type: 'boolean' | 'multiple';
difficulty: string;
category: string;
}
export interface TriviaSession {
sessionId: string;
userId: string;
question: TriviaQuestion;
allAnswers: string[];
correctIndex: number;
expiresAt: Date;
entryFee: bigint;
potentialReward: bigint;
}
export interface TriviaResult {
correct: boolean;
reward: bigint;
correctAnswer: string;
}
class TriviaService {
private activeSessions: Map<string, TriviaSession> = new Map();
constructor() {
// Cleanup expired sessions every 30 seconds
setInterval(() => {
this.cleanupExpiredSessions();
}, 30000);
}
private cleanupExpiredSessions() {
const now = Date.now();
const expired: string[] = [];
for (const [sessionId, session] of this.activeSessions.entries()) {
if (session.expiresAt.getTime() < now) {
expired.push(sessionId);
}
}
for (const sessionId of expired) {
this.activeSessions.delete(sessionId);
}
if (expired.length > 0) {
console.log(`[TriviaService] Cleaned up ${expired.length} expired sessions.`);
}
}
/**
* Fetch a trivia question from OpenTDB API
*/
async fetchQuestion(category?: number, difficulty?: string): Promise<TriviaQuestion> {
let url = 'https://opentdb.com/api.php?amount=1&encode=base64';
if (category) {
url += `&category=${category}`;
}
if (difficulty && difficulty !== 'random') {
url += `&difficulty=${difficulty}`;
}
try {
const response = await fetch(url);
const data = await response.json() as OpenTDBResponse;
if (data.response_code !== 0 || !data.results || data.results.length === 0) {
throw new Error('Failed to fetch trivia question');
}
const result = data.results[0];
if (!result) {
throw new Error('No trivia question returned');
}
// Decode base64
return {
category: Buffer.from(result.category, 'base64').toString('utf-8'),
type: result.type,
difficulty: Buffer.from(result.difficulty, 'base64').toString('utf-8'),
question: Buffer.from(result.question, 'base64').toString('utf-8'),
correctAnswer: Buffer.from(result.correct_answer, 'base64').toString('utf-8'),
incorrectAnswers: result.incorrect_answers.map(ans =>
Buffer.from(ans, 'base64').toString('utf-8')
),
};
} catch (error) {
console.error('[TriviaService] Error fetching question:', error);
throw new UserError('Failed to fetch trivia question. Please try again later.');
}
}
/**
* Check if user can play trivia (cooldown check)
*/
async canPlayTrivia(userId: string): Promise<{ canPlay: boolean; nextAvailable?: Date }> {
const now = new Date();
const cooldown = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
eq(userTimers.key, 'default')
),
});
if (cooldown && cooldown.expiresAt > now) {
return { canPlay: false, nextAvailable: cooldown.expiresAt };
}
return { canPlay: true };
}
/**
* Start a trivia session - deducts entry fee and creates session
*/
async startTrivia(userId: string, username: string): Promise<TriviaSession> {
// Check cooldown
const cooldownCheck = await this.canPlayTrivia(userId);
if (!cooldownCheck.canPlay) {
const timestamp = Math.floor(cooldownCheck.nextAvailable!.getTime() / 1000);
throw new UserError(`You're on cooldown! Try again <t:${timestamp}:R>.`);
}
const entryFee = config.trivia.entryFee;
return await withTransaction(async (tx) => {
// Check balance
const user = await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId)),
});
if (!user) {
throw new UserError('User not found.');
}
if ((user.balance ?? 0n) < entryFee) {
throw new UserError(`Insufficient funds! You need ${entryFee} AU to play trivia.`);
}
// Deduct entry fee (SINK MECHANISM)
await tx.update(users)
.set({
balance: (user.balance ?? 0n) - entryFee,
})
.where(eq(users.id, BigInt(userId)));
// Record transaction
await tx.insert(transactions).values({
userId: BigInt(userId),
amount: -entryFee,
type: TransactionType.TRIVIA_ENTRY,
description: 'Trivia entry fee',
});
// Fetch question
const category = config.trivia.categories.length > 0
? config.trivia.categories[Math.floor(Math.random() * config.trivia.categories.length)]
: undefined;
const difficulty = config.trivia.difficulty;
const question = await this.fetchQuestion(category, difficulty);
// Shuffle answers
const allAnswers = [...question.incorrectAnswers, question.correctAnswer];
const shuffled = allAnswers.sort(() => Math.random() - 0.5);
const correctIndex = shuffled.indexOf(question.correctAnswer);
// Create session
const sessionId = `${userId}_${Date.now()}`;
const expiresAt = new Date(Date.now() + config.trivia.timeoutSeconds * 1000);
const potentialReward = BigInt(Math.floor(Number(entryFee) * config.trivia.rewardMultiplier));
const session: TriviaSession = {
sessionId,
userId,
question,
allAnswers: shuffled,
correctIndex,
expiresAt,
entryFee,
potentialReward,
};
this.activeSessions.set(sessionId, session);
// Set cooldown
const cooldownEnd = new Date(Date.now() + config.trivia.cooldownMs);
await tx.insert(userTimers)
.values({
userId: BigInt(userId),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: cooldownEnd,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: cooldownEnd },
});
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'info',
message: `${username} started a trivia game (${question.difficulty})`,
icon: '🎯'
});
return session;
});
}
/**
* Get session by ID
*/
getSession(sessionId: string): TriviaSession | undefined {
return this.activeSessions.get(sessionId);
}
/**
* Submit answer and process reward
*/
async submitAnswer(sessionId: string, userId: string, isCorrect: boolean): Promise<TriviaResult> {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new UserError('Session not found or expired.');
}
if (session.userId !== userId) {
throw new UserError('This is not your trivia question!');
}
// Remove session to prevent double-submit
this.activeSessions.delete(sessionId);
const reward = isCorrect ? session.potentialReward : 0n;
if (isCorrect) {
await withTransaction(async (tx) => {
// Award prize
await tx.update(users)
.set({
balance: (await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId))
}))!.balance! + reward,
})
.where(eq(users.id, BigInt(userId)));
// Record transaction
await tx.insert(transactions).values({
userId: BigInt(userId),
amount: reward,
type: TransactionType.TRIVIA_WIN,
description: 'Trivia prize',
});
// Record dashboard event
const user = await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId))
});
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user?.username} won ${reward.toLocaleString()} AU from trivia!`,
icon: '🎉'
});
});
}
return {
correct: isCorrect,
reward,
correctAnswer: session.question.correctAnswer,
};
}
}
export const triviaService = new TriviaService();