feat: Implement chat XP with cooldowns and display an XP progress bar on the student ID card.

This commit is contained in:
syntaxbullet
2025-12-09 12:04:03 +01:00
parent 90a1861416
commit 9250057574
3 changed files with 150 additions and 5 deletions

View File

@@ -1,4 +1,5 @@
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
import { levelingService } from '@/modules/leveling/leveling.service';
import path from 'path';
// Register Fonts
@@ -123,5 +124,36 @@ export async function generateStudentIdCard(data: StudentCardData): Promise<Buff
ctx.fillText((data.level + 1).toString(), 431, 255);
}
ctx.restore();
// Draw xp bar
const xpbarX = 2;
const xpbarY = 268;
const xpbarMaxWidth = 400; // in pixels
const xpbarHeight = 4;
const xp = data.xp;
const requiredXp = BigInt(levelingService.getXpForLevel(data.level));
// Check to avoid division by zero, though requiredXp should be >= 100
let percentage = 0;
if (requiredXp > 0n) {
percentage = Number(xp) / Number(requiredXp);
}
const xpFilledWidth = Math.min(Math.max(percentage * xpbarMaxWidth, 0), xpbarMaxWidth);
const gradient = ctx.createLinearGradient(xpbarX, xpbarY, xpbarX + xpFilledWidth, xpbarY);
gradient.addColorStop(0, '#FFFFFF');
gradient.addColorStop(1, '#D9B178');
ctx.save();
ctx.fillStyle = accentColor;
ctx.fillRect(xpbarX, xpbarY, xpbarMaxWidth, xpbarHeight);
if (xpFilledWidth > 0) {
ctx.fillStyle = gradient;
ctx.fillRect(xpbarX, xpbarY, xpFilledWidth, xpbarHeight);
}
ctx.restore();
return canvas.toBuffer('image/png');
}

View File

@@ -1,10 +1,13 @@
import { users } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
import { users, cooldowns } from "@/db/schema";
import { eq, sql, and } from "drizzle-orm";
import { DrizzleClient } from "@/lib/DrizzleClient";
// Simple configurable curve: Base * (Level ^ Exponent)
const XP_BASE = 1000;
const XP_EXPONENT = 1.5;
const XP_BASE = 100;
const XP_EXPONENT = 2.5;
const CHAT_XP_COOLDOWN_MS = 60000; // 1 minute
const MIN_CHAT_XP = 15;
const MAX_CHAT_XP = 25;
export const levelingService = {
// Calculate XP required for a specific level
@@ -12,6 +15,7 @@ export const levelingService = {
return Math.floor(XP_BASE * Math.pow(level, XP_EXPONENT));
},
// Pure XP addition - No cooldown checks
addXp: async (id: string, amount: bigint, tx?: any) => {
const execute = async (txFn: any) => {
// Get current state
@@ -45,7 +49,7 @@ export const levelingService = {
.returning();
return { user: updatedUser, levelUp, currentLevel };
}
};
if (tx) {
return await execute(tx);
@@ -55,4 +59,52 @@ export const levelingService = {
})
}
},
// Handle chat XP with cooldowns
processChatXp: async (id: string, tx?: any) => {
const execute = async (txFn: any) => {
// check if an xp cooldown is in place
const cooldown = await txFn.query.cooldowns.findFirst({
where: and(
eq(cooldowns.userId, BigInt(id)),
eq(cooldowns.actionKey, 'xp')
),
});
const now = new Date();
if (cooldown && cooldown.readyAt > now) {
return { awarded: false, reason: 'cooldown' };
}
// Calculate random XP
const amount = BigInt(Math.floor(Math.random() * (MAX_CHAT_XP - MIN_CHAT_XP + 1)) + MIN_CHAT_XP);
// Add XP
const result = await levelingService.addXp(id, amount, txFn);
// Update/Set Cooldown
const nextReadyAt = new Date(now.getTime() + CHAT_XP_COOLDOWN_MS);
await txFn.insert(cooldowns)
.values({
userId: BigInt(id),
actionKey: 'xp',
readyAt: nextReadyAt,
})
.onConflictDoUpdate({
target: [cooldowns.userId, cooldowns.actionKey],
set: { readyAt: nextReadyAt },
});
return { awarded: true, amount, ...result };
};
if (tx) {
return await execute(tx);
} else {
return await DrizzleClient.transaction(async (t) => {
return await execute(t);
})
}
}
};

View File

@@ -0,0 +1,61 @@
import { DrizzleClient } from "@/lib/DrizzleClient";
import { eq } from "drizzle-orm";
import { userService } from "@/modules/user/user.service";
import { classService } from "@/modules/class/class.service";
import { generateStudentIdCard } from "@/graphics/studentID";
import fs from 'fs';
import path from 'path';
async function main() {
console.log("Fetching first user from database...");
// Get the first user
const user = await DrizzleClient.query.users.findFirst({ where: (users) => eq(users.id, BigInt(109998942841765888)) });
if (!user) {
console.error("No users found in database. Please ensure the database is seeded or has at least one user.");
process.exit(1);
}
console.log(`Found user: ${user.username} (${user.id})`);
// Get user class
const userClass = await userService.getUserClass(user.id.toString());
const className = userClass?.name || "Unknown";
console.log(`User Class: ${className}`);
// Get class balance
const classBalance = await classService.getClassBalance(userClass?.id || BigInt(0));
console.log(`Class Balance: ${classBalance}`);
// Placeholder avatar (default discord avatar)
const avatarUrl = "https://cdn.discordapp.com/embed/avatars/0.png";
console.log("Generating Student ID Card...");
try {
const cardBuffer = await generateStudentIdCard({
username: user.username,
avatarUrl: avatarUrl,
id: user.id.toString(),
level: user.level || 1,
xp: user.xp || 0n,
au: user.balance || 0n,
cu: classBalance || 0n,
className: 'D'
});
const outputPath = path.join(process.cwd(), 'test-student-id.png');
fs.writeFileSync(outputPath, cardBuffer);
console.log(`Student ID card generated successfully: ${outputPath}`);
} catch (error) {
console.error("Error generating student ID card:", error);
} finally {
// Exit cleanly
process.exit(0);
}
}
main().catch(console.error);