mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-06-07 15:39:30 +00:00
feat: add achievement system
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
This commit is contained in:
parent
830838a6a1
commit
2f5c3499e7
15 changed files with 1966 additions and 37 deletions
423
src/db/db.ts
423
src/db/db.ts
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue