feat: add achievement system

Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
This commit is contained in:
Ahmad 2025-04-16 16:52:44 -04:00
parent 830838a6a1
commit 2f5c3499e7
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
15 changed files with 1966 additions and 37 deletions

View file

@ -430,31 +430,36 @@ export async function getUserLevel(
const cacheKey = `level-${discordId}`;
return await withCache<schema.levelTableTypes>(cacheKey, async () => {
const level = await db
.select()
.from(schema.levelTable)
.where(eq(schema.levelTable.discordId, discordId))
.then((rows) => rows[0]);
return await withCache<schema.levelTableTypes>(
cacheKey,
async () => {
const level = await db
.select()
.from(schema.levelTable)
.where(eq(schema.levelTable.discordId, discordId))
.then((rows) => rows[0]);
if (level) {
return {
...level,
lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
if (level) {
return {
...level,
lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
};
}
const newLevel: schema.levelTableTypes = {
discordId,
xp: 0,
level: 0,
lastMessageTimestamp: new Date(),
messagesSent: 0,
reactionCount: 0,
};
}
const newLevel: schema.levelTableTypes = {
discordId,
xp: 0,
level: 0,
lastMessageTimestamp: new Date(),
messagesSent: 0,
};
await db.insert(schema.levelTable).values(newLevel);
return newLevel;
});
await db.insert(schema.levelTable).values(newLevel);
return newLevel;
},
300,
);
} catch (error) {
return handleDbError('Error getting user level', error as Error);
}
@ -484,8 +489,11 @@ export async function addXpToUser(
const cacheKey = `level-${discordId}`;
const userData = await getUserLevel(discordId);
const currentLevel = userData.level;
const currentXp = Number(userData.xp);
const xpToAdd = Number(amount);
userData.xp = currentXp + xpToAdd;
userData.xp += amount;
userData.lastMessageTimestamp = new Date();
userData.level = calculateLevelFromXp(userData.xp);
userData.messagesSent += 1;
@ -596,6 +604,93 @@ async function getLeaderboardData(): Promise<
}
}
/**
* Increments the user's reaction count
* @param userId - Discord user ID
* @returns The updated reaction count
*/
export async function incrementUserReactionCount(
userId: string,
): Promise<number> {
try {
await ensureDbInitialized();
if (!db) {
console.error(
'Database not initialized, cannot increment reaction count',
);
}
const levelData = await getUserLevel(userId);
const newCount = (levelData.reactionCount || 0) + 1;
await db
.update(schema.levelTable)
.set({ reactionCount: newCount })
.where(eq(schema.levelTable.discordId, userId));
await invalidateCache(`level-${userId}`);
return newCount;
} catch (error) {
console.error('Error incrementing user reaction count:', error);
return 0;
}
}
/**
* Decrements the user's reaction count (but not below zero)
* @param userId - Discord user ID
* @returns The updated reaction count
*/
export async function decrementUserReactionCount(
userId: string,
): Promise<number> {
try {
await ensureDbInitialized();
if (!db) {
console.error(
'Database not initialized, cannot increment reaction count',
);
}
const levelData = await getUserLevel(userId);
const newCount = Math.max(0, levelData.reactionCount - 1);
await db
.update(schema.levelTable)
.set({ reactionCount: newCount < 0 ? 0 : newCount })
.where(eq(schema.levelTable.discordId, userId));
await invalidateCache(`level-${userId}`);
return newCount;
} catch (error) {
console.error('Error decrementing user reaction count:', error);
return 0;
}
}
/**
* Gets the user's reaction count
* @param userId - Discord user ID
* @returns The user's reaction count
*/
export async function getUserReactionCount(userId: string): Promise<number> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot get user reaction count');
}
const levelData = await getUserLevel(userId);
return levelData.reactionCount;
} catch (error) {
console.error('Error getting user reaction count:', error);
return 0;
}
}
/**
* Get the XP leaderboard
* @param limit - Number of entries to return
@ -1191,3 +1286,285 @@ export async function rerollGiveaway(
return handleDbError('Failed to reroll giveaway', error as Error);
}
}
// ========================
// Achievement Functions
// ========================
/**
* Get all achievement definitions
* @returns Array of achievement definitions
*/
export async function getAllAchievements(): Promise<
schema.achievementDefinitionsTableTypes[]
> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot get achievements');
return [];
}
return await db
.select()
.from(schema.achievementDefinitionsTable)
.orderBy(schema.achievementDefinitionsTable.threshold);
} catch (error) {
return handleDbError('Failed to get all achievements', error as Error);
}
}
/**
* Get achievements for a specific user
* @param userId - Discord ID of the user
* @returns Array of user achievements
*/
export async function getUserAchievements(
userId: string,
): Promise<schema.userAchievementsTableTypes[]> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot get user achievements');
return [];
}
return await db
.select({
id: schema.userAchievementsTable.id,
discordId: schema.userAchievementsTable.discordId,
achievementId: schema.userAchievementsTable.achievementId,
earnedAt: schema.userAchievementsTable.earnedAt,
progress: schema.userAchievementsTable.progress,
})
.from(schema.userAchievementsTable)
.where(eq(schema.userAchievementsTable.discordId, userId));
} catch (error) {
return handleDbError('Failed to get user achievements', error as Error);
}
}
/**
* Award an achievement to a user
* @param userId - Discord ID of the user
* @param achievementId - ID of the achievement
* @returns Boolean indicating success
*/
export async function awardAchievement(
userId: string,
achievementId: number,
): Promise<boolean> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot award achievement');
return false;
}
const existing = await db
.select()
.from(schema.userAchievementsTable)
.where(
and(
eq(schema.userAchievementsTable.discordId, userId),
eq(schema.userAchievementsTable.achievementId, achievementId),
),
)
.then((rows) => rows[0]);
if (existing) {
if (existing.earnedAt) {
return false;
}
await db
.update(schema.userAchievementsTable)
.set({
earnedAt: new Date(),
progress: 100,
})
.where(eq(schema.userAchievementsTable.id, existing.id));
} else {
await db.insert(schema.userAchievementsTable).values({
discordId: userId,
achievementId: achievementId,
earnedAt: new Date(),
progress: 100,
});
}
return true;
} catch (error) {
handleDbError('Failed to award achievement', error as Error);
return false;
}
}
/**
* Update achievement progress for a user
* @param userId - Discord ID of the user
* @param achievementId - ID of the achievement
* @param progress - Progress value (0-100)
* @returns Boolean indicating success
*/
export async function updateAchievementProgress(
userId: string,
achievementId: number,
progress: number,
): Promise<boolean> {
try {
await ensureDbInitialized();
if (!db) {
console.error(
'Database not initialized, cannot update achievement progress',
);
return false;
}
const existing = await db
.select()
.from(schema.userAchievementsTable)
.where(
and(
eq(schema.userAchievementsTable.discordId, userId),
eq(schema.userAchievementsTable.achievementId, achievementId),
),
)
.then((rows) => rows[0]);
if (existing) {
if (existing.earnedAt) {
return false;
}
await db
.update(schema.userAchievementsTable)
.set({
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress),
})
.where(eq(schema.userAchievementsTable.id, existing.id));
} else {
await db.insert(schema.userAchievementsTable).values({
discordId: userId,
achievementId: achievementId,
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress),
});
}
return true;
} catch (error) {
handleDbError('Failed to update achievement progress', error as Error);
return false;
}
}
/**
* Create a new achievement definition
* @param achievementData - Achievement definition data
* @returns Created achievement or undefined on failure
*/
export async function createAchievement(achievementData: {
name: string;
description: string;
imageUrl?: string;
requirementType: string;
threshold: number;
requirement?: any;
rewardType?: string;
rewardValue?: string;
}): Promise<schema.achievementDefinitionsTableTypes | undefined> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot create achievement');
return undefined;
}
const [achievement] = await db
.insert(schema.achievementDefinitionsTable)
.values({
name: achievementData.name,
description: achievementData.description,
imageUrl: achievementData.imageUrl || null,
requirementType: achievementData.requirementType,
threshold: achievementData.threshold,
requirement: achievementData.requirement || {},
rewardType: achievementData.rewardType || null,
rewardValue: achievementData.rewardValue || null,
})
.returning();
return achievement;
} catch (error) {
return handleDbError('Failed to create achievement', error as Error);
}
}
/**
* Delete an achievement definition
* @param achievementId - ID of the achievement to delete
* @returns Boolean indicating success
*/
export async function deleteAchievement(
achievementId: number,
): Promise<boolean> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot delete achievement');
return false;
}
await db
.delete(schema.userAchievementsTable)
.where(eq(schema.userAchievementsTable.achievementId, achievementId));
await db
.delete(schema.achievementDefinitionsTable)
.where(eq(schema.achievementDefinitionsTable.id, achievementId));
return true;
} catch (error) {
handleDbError('Failed to delete achievement', error as Error);
return false;
}
}
/**
* Removes an achievement from a user
* @param discordId - Discord user ID
* @param achievementId - Achievement ID to remove
* @returns boolean indicating success
*/
export async function removeUserAchievement(
discordId: string,
achievementId: number,
): Promise<boolean> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot remove user achievement');
return false;
}
await db
.delete(schema.userAchievementsTable)
.where(
and(
eq(schema.userAchievementsTable.discordId, discordId),
eq(schema.userAchievementsTable.achievementId, achievementId),
),
);
return true;
} catch (error) {
handleDbError('Failed to remove user achievement', error as Error);
return false;
}
}

View file

@ -1,6 +1,7 @@
import {
boolean,
integer,
json,
jsonb,
pgTable,
timestamp,
@ -32,6 +33,7 @@ export interface levelTableTypes {
xp: number;
level: number;
messagesSent: number;
reactionCount: number;
lastMessageTimestamp?: Date;
}
@ -43,6 +45,7 @@ export const levelTable = pgTable('levels', {
xp: integer('xp').notNull().default(0),
level: integer('level').notNull().default(0),
messagesSent: integer('messages_sent').notNull().default(0),
reactionCount: integer('reaction_count').notNull().default(0),
lastMessageTimestamp: timestamp('last_message_timestamp'),
});
@ -143,3 +146,36 @@ export const giveawayTable = pgTable('giveaways', {
requireAllCriteria: boolean('require_all_criteria').default(true),
bonusEntries: jsonb('bonus_entries').default({}),
});
export type userAchievementsTableTypes = InferSelectModel<
typeof userAchievementsTable
>;
export const userAchievementsTable = pgTable('user_achievements', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
discordId: varchar('user_id', { length: 50 })
.notNull()
.references(() => memberTable.discordId),
achievementId: integer('achievement_id')
.notNull()
.references(() => achievementDefinitionsTable.id),
earnedAt: timestamp('earned_at'),
progress: integer().default(0),
});
export type achievementDefinitionsTableTypes = InferSelectModel<
typeof achievementDefinitionsTable
>;
export const achievementDefinitionsTable = pgTable('achievement_definitions', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 100 }).notNull(),
description: varchar({ length: 255 }).notNull(),
imageUrl: varchar('image_url', { length: 255 }),
requirement: json().notNull(),
requirementType: varchar('requirement_type', { length: 50 }).notNull(),
threshold: integer().notNull(),
rewardType: varchar('reward_type', { length: 50 }),
rewardValue: varchar('reward_value', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});