Compare commits
2 Commits
3a96b67e89
...
6d54695325
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d54695325 | ||
|
|
8c1f80981b |
186
src/commands/economy/exam.ts
Normal file
186
src/commands/economy/exam.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { createCommand } from "@/lib/utils";
|
||||||
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
|
import { userService } from "@/modules/user/user.service";
|
||||||
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
|
import { userTimers, users } from "@/db/schema";
|
||||||
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
|
import config from "@/config/config.json";
|
||||||
|
|
||||||
|
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM';
|
||||||
|
const EXAM_TIMER_KEY = 'default';
|
||||||
|
|
||||||
|
interface ExamMetadata {
|
||||||
|
examDay: number;
|
||||||
|
lastXp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
|
export const exam = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("exam")
|
||||||
|
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
const now = new Date();
|
||||||
|
const currentDay = now.getDay();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch existing timer/exam data
|
||||||
|
const timer = await DrizzleClient.query.userTimers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(userTimers.userId, user.id),
|
||||||
|
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||||
|
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. First Run Logic
|
||||||
|
if (!timer) {
|
||||||
|
// Set exam day to today
|
||||||
|
const nextWeek = new Date(now);
|
||||||
|
nextWeek.setDate(now.getDate() + 7);
|
||||||
|
|
||||||
|
const metadata: ExamMetadata = {
|
||||||
|
examDay: currentDay,
|
||||||
|
lastXp: user.xp.toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await DrizzleClient.insert(userTimers).values({
|
||||||
|
userId: user.id,
|
||||||
|
type: EXAM_TIMER_TYPE,
|
||||||
|
key: EXAM_TIMER_KEY,
|
||||||
|
expiresAt: nextWeek,
|
||||||
|
metadata: metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(
|
||||||
|
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}**.\n` +
|
||||||
|
`Come back next week on **${DAYS[currentDay]}** to take your first exam and earn rewards based on your XP gain!`,
|
||||||
|
"Exam Registration Successful"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = timer.metadata as unknown as ExamMetadata;
|
||||||
|
const examDay = metadata.examDay;
|
||||||
|
|
||||||
|
// 3. Cooldown Check
|
||||||
|
if (now < new Date(timer.expiresAt)) {
|
||||||
|
// Calculate time remaining
|
||||||
|
const expiresAt = new Date(timer.expiresAt);
|
||||||
|
// Simple formatting
|
||||||
|
const timestamp = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
|
`Next exam available: <t:${timestamp}:R> (${DAYS[examDay]})`
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Day Check
|
||||||
|
if (currentDay !== examDay) {
|
||||||
|
// "If not executed on same weekday... we do not reward"
|
||||||
|
// Consume the attempt (reset timer) but give 0 reward.
|
||||||
|
|
||||||
|
const nextWeek = new Date(now);
|
||||||
|
nextWeek.setDate(now.getDate() + 7);
|
||||||
|
|
||||||
|
const newMetadata: ExamMetadata = {
|
||||||
|
examDay: examDay,
|
||||||
|
lastXp: user.xp.toString() // Reset tracking
|
||||||
|
};
|
||||||
|
|
||||||
|
await DrizzleClient.update(userTimers)
|
||||||
|
.set({
|
||||||
|
expiresAt: nextWeek,
|
||||||
|
metadata: newMetadata
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(userTimers.userId, user.id),
|
||||||
|
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||||
|
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||||
|
));
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed(
|
||||||
|
`You missed your exam day! Your exam is on **${DAYS[examDay]}**, but today is ${DAYS[currentDay]}.\n` +
|
||||||
|
`You verify your attendance but score a **0**. Come back next **${DAYS[examDay]}**!`,
|
||||||
|
"Exam Failed"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Reward Calculation
|
||||||
|
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
|
||||||
|
const currentXp = user.xp;
|
||||||
|
const diff = currentXp - lastXp;
|
||||||
|
|
||||||
|
// Calculate Reward
|
||||||
|
const multMin = config.economy.exam.multMin;
|
||||||
|
const multMax = config.economy.exam.multMax;
|
||||||
|
const multiplier = Math.random() * (multMax - multMin) + multMin;
|
||||||
|
|
||||||
|
// Allow negative reward? existing description implies "difference", usually gain.
|
||||||
|
// If diff is negative (lost XP?), reward might be 0.
|
||||||
|
let reward = 0n;
|
||||||
|
if (diff > 0n) {
|
||||||
|
reward = BigInt(Math.floor(Number(diff) * multiplier));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Update State
|
||||||
|
const nextWeek = new Date(now);
|
||||||
|
nextWeek.setDate(now.getDate() + 7);
|
||||||
|
|
||||||
|
const newMetadata: ExamMetadata = {
|
||||||
|
examDay: examDay,
|
||||||
|
lastXp: currentXp.toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await DrizzleClient.transaction(async (tx) => {
|
||||||
|
// Update Timer
|
||||||
|
await tx.update(userTimers)
|
||||||
|
.set({
|
||||||
|
expiresAt: nextWeek,
|
||||||
|
metadata: newMetadata
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(userTimers.userId, user.id),
|
||||||
|
eq(userTimers.type, EXAM_TIMER_TYPE),
|
||||||
|
eq(userTimers.key, EXAM_TIMER_KEY)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add Currency
|
||||||
|
if (reward > 0n) {
|
||||||
|
await tx.update(users)
|
||||||
|
.set({
|
||||||
|
balance: sql`${users.balance} + ${reward}`
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createSuccessEmbed(
|
||||||
|
`**XP Gained:** ${diff.toString()}\n` +
|
||||||
|
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
||||||
|
`**Reward:** ${reward.toString()} Currency\n\n` +
|
||||||
|
`See you next week on **${DAYS[examDay]}**!`,
|
||||||
|
"Exam Passed!"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Exam command error:", error);
|
||||||
|
await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while processing your exam.")] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -19,34 +19,6 @@ export const use = createCommand({
|
|||||||
.setAutocomplete(true)
|
.setAutocomplete(true)
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
if (!interaction.isChatInputCommand()) {
|
|
||||||
if (interaction.isAutocomplete()) {
|
|
||||||
const focusedValue = interaction.options.getFocused();
|
|
||||||
const userId = interaction.user.id;
|
|
||||||
|
|
||||||
// Fetch owned items that are usable
|
|
||||||
const userInventory = await DrizzleClient.query.inventory.findMany({
|
|
||||||
where: eq(inventory.userId, BigInt(userId)),
|
|
||||||
with: {
|
|
||||||
item: true
|
|
||||||
},
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
const filtered = userInventory.filter(entry => {
|
|
||||||
const matchName = entry.item.name.toLowerCase().includes(focusedValue.toLowerCase());
|
|
||||||
const usageData = entry.item.usageData as ItemUsageData | null;
|
|
||||||
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
|
|
||||||
return matchName && isUsable;
|
|
||||||
});
|
|
||||||
|
|
||||||
await interaction.respond(
|
|
||||||
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
@@ -55,14 +27,6 @@ export const use = createCommand({
|
|||||||
try {
|
try {
|
||||||
const result = await inventoryService.useItem(user.id, itemId);
|
const result = await inventoryService.useItem(user.id, itemId);
|
||||||
|
|
||||||
// Check for side effects like Role assignment that need Discord API access
|
|
||||||
// The service returns the usageData, so we can re-check simple effects or just check the results log?
|
|
||||||
// Actually, we put "TEMP_ROLE" inside results log, AND we can check usageData here for strict role assignment if we want to separate concerns.
|
|
||||||
// But for now, let's rely on the service to have handled database state, and we handle Discord state here if needed?
|
|
||||||
// WAIT - I put the role assignment placeholder in the service but it returned a result string.
|
|
||||||
// The service cannot assign the role directly because it doesn't have the member object easily (requires fetching).
|
|
||||||
// So we should iterate results or usageData here.
|
|
||||||
|
|
||||||
const usageData = result.usageData;
|
const usageData = result.usageData;
|
||||||
if (usageData) {
|
if (usageData) {
|
||||||
for (const effect of usageData.effects) {
|
for (const effect of usageData.effects) {
|
||||||
@@ -91,5 +55,29 @@ export const use = createCommand({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
autocomplete: async (interaction) => {
|
||||||
|
const focusedValue = interaction.options.getFocused();
|
||||||
|
const userId = interaction.user.id;
|
||||||
|
|
||||||
|
// Fetch owned items that are usable
|
||||||
|
const userInventory = await DrizzleClient.query.inventory.findMany({
|
||||||
|
where: eq(inventory.userId, BigInt(userId)),
|
||||||
|
with: {
|
||||||
|
item: true
|
||||||
|
},
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = userInventory.filter(entry => {
|
||||||
|
const matchName = entry.item.name.toLowerCase().includes(focusedValue.toLowerCase());
|
||||||
|
const usageData = entry.item.usageData as ItemUsageData | null;
|
||||||
|
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
|
||||||
|
return matchName && isUsable;
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.respond(
|
||||||
|
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
"streakBonus": "10",
|
"streakBonus": "10",
|
||||||
"cooldownMs": 86400000
|
"cooldownMs": 86400000
|
||||||
},
|
},
|
||||||
|
"exam": {
|
||||||
|
"multMin": 0.5,
|
||||||
|
"multMax": 1.5,
|
||||||
|
"cooldownHours": 168
|
||||||
|
},
|
||||||
"transfers": {
|
"transfers": {
|
||||||
"allowSelfTransfer": false,
|
"allowSelfTransfer": false,
|
||||||
"minAmount": "1"
|
"minAmount": "1"
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ const event: Event<Events.InteractionCreate> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (interaction.isAutocomplete()) {
|
||||||
|
const command = KyokoClient.commands.get(interaction.commandName);
|
||||||
|
if (!command || !command.autocomplete) return;
|
||||||
|
try {
|
||||||
|
await command.autocomplete(interaction);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
const command = KyokoClient.commands.get(interaction.commandName);
|
const command = KyokoClient.commands.get(interaction.commandName);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||||
|
|
||||||
export interface Command {
|
export interface Command {
|
||||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||||
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
||||||
|
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
|
||||||
category?: string;
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user