Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions backend/controllers/debatevsbot_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t
case "loss":
pointsToAdd = 10 // Participation points
action = "debate_loss"
case "concede":
pointsToAdd = 0 // No points for conceding
action = "debate_concede"
case "draw":
pointsToAdd = 25 // Points for draw
action = "debate_complete"
Expand Down Expand Up @@ -448,7 +451,9 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t
}

// Check for automatic badges (Novice, Streak5, FactMaster, etc.)
checkAndAwardAutomaticBadges(ctx, userID, updatedUser)
if resultStatus != "concede" {
checkAndAwardAutomaticBadges(ctx, userID, updatedUser)
}

// Broadcast score update via WebSocket
websocket.BroadcastGamificationEvent(models.GamificationEvent{
Expand Down Expand Up @@ -589,7 +594,7 @@ func ConcedeDebate(c *gin.Context) {
"user_vs_bot",
debate.Topic,
debate.BotName,
"loss",
"concede",
historyToSave,
nil,
)
Expand All @@ -602,7 +607,7 @@ func ConcedeDebate(c *gin.Context) {
log.Printf("Panic in updateGamificationAfterBotDebate (concede): %v", r)
}
}()
updateGamificationAfterBotDebate(user.ID, "loss", debate.Topic)
updateGamificationAfterBotDebate(user.ID, "concede", debate.Topic)
}()

c.JSON(200, gin.H{"message": "Debate conceded successfully"})
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import CommunityFeed from './Pages/CommunityFeed';
import AdminSignup from './Pages/Admin/AdminSignup';
import AdminDashboard from './Pages/Admin/AdminDashboard';
import ViewDebate from './Pages/ViewDebate';
import { Toaster } from '@/components/ui/toaster';
import GamificationOverlay from './components/GamificationOverlay';

// Protects routes based on authentication status
function ProtectedRoute() {
Expand Down Expand Up @@ -110,6 +112,8 @@ function App() {
<AuthProvider>
<ThemeProvider>
<AppRoutes />
<GamificationOverlay />
<Toaster />
</ThemeProvider>
</AuthProvider>
);
Expand Down
32 changes: 0 additions & 32 deletions frontend/src/Pages/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
createGamificationWebSocket,
GamificationEvent,
} from "@/services/gamificationService";
import BadgeUnlocked from "@/components/BadgeUnlocked";
import { useUser } from "@/hooks/useUser";

interface Debater {
Expand Down Expand Up @@ -77,13 +76,6 @@ const Leaderboard: React.FC = () => {
const [stats, setStats] = useState<Stat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [badgeUnlocked, setBadgeUnlocked] = useState<{
badgeName: string;
isOpen: boolean;
}>({
badgeName: "",
isOpen: false,
});
const [sortCategory, setSortCategory] = useState<SortCategory>("score");
const wsRef = useRef<WebSocket | null>(null);
const { user } = useUser();
Expand Down Expand Up @@ -134,25 +126,6 @@ const Leaderboard: React.FC = () => {
(event: GamificationEvent) => {
console.log("Gamification event received:", event);

if (event.type === "badge_awarded" && event.badgeName) {
// Show badge unlock notification if it's for the current user
setDebaters((currentDebaters) => {
const currentUserDebater = currentDebaters.find(
(d) => d.currentUser
);
if (
event.userId === currentUserDebater?.id ||
event.userId === user?.id
) {
setBadgeUnlocked({
badgeName: event.badgeName!,
isOpen: true,
});
}
return currentDebaters;
});
}

if (event.type === "score_updated") {
// Update the leaderboard when scores change
setDebaters((prevDebaters) => {
Expand Down Expand Up @@ -263,11 +236,6 @@ const Leaderboard: React.FC = () => {

return (
<div className="p-6 bg-background text-foreground">
<BadgeUnlocked
badgeName={badgeUnlocked.badgeName}
isOpen={badgeUnlocked.isOpen}
onClose={() => setBadgeUnlocked({ badgeName: "", isOpen: false })}
/>
<div className="max-w-7xl mx-auto">
<p className="text-center text-muted-foreground mb-8 text-lg">
Hone your skills and see how you stack up against top debaters! 🏆
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/BadgeUnlocked.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import Confetti from "react-confetti";
import { FaTrophy, FaMedal, FaAward } from "react-icons/fa";
import { FaTrophy, FaMedal, FaAward, FaCrown } from "react-icons/fa";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

Expand All @@ -16,6 +16,7 @@ const badgeIcons: Record<string, React.ReactNode> = {
FactMaster: <FaTrophy className="w-16 h-16 text-purple-500" />,
FirstWin: <FaTrophy className="w-16 h-16 text-green-500" />,
Debater10: <FaMedal className="w-16 h-16 text-orange-500" />,
Top10: <FaCrown className="w-16 h-16 text-yellow-400" />,
};

const badgeDescriptions: Record<string, string> = {
Expand All @@ -24,6 +25,7 @@ const badgeDescriptions: Record<string, string> = {
FactMaster: "You're a master of facts! Keep up the great work!",
FirstWin: "Congratulations on your first victory!",
Debater10: "You've completed 10 debates! You're becoming a pro!",
Top10: "You've reached the Top 10! You are among the elite debaters!",
};

const BadgeUnlocked: React.FC<BadgeUnlockedProps> = ({ badgeName, isOpen, onClose }) => {
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/components/GamificationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React, { useEffect, useState, useRef } from "react";
import { useUser } from "@/hooks/useUser";
import {
createGamificationWebSocket,
GamificationEvent,
fetchGamificationLeaderboard,
} from "@/services/gamificationService";
import BadgeUnlocked from "@/components/BadgeUnlocked";

const GamificationOverlay: React.FC = () => {
const { user } = useUser();
const [badgeUnlocked, setBadgeUnlocked] = useState<{
badgeName: string;
isOpen: boolean;
}>({
badgeName: "",
isOpen: false,
});
const wsRef = useRef<WebSocket | null>(null);
const previousRankRef = useRef<number | null>(null);

useEffect(() => {
const token = localStorage.getItem("token");
if (!token || !user) return;

// Initial rank check
fetchGamificationLeaderboard(token).then((data) => {
const myEntry = data.debaters.find(d => d.id === user.id);
if (myEntry) {
previousRankRef.current = myEntry.rank;
}
}).catch(console.error);
Comment on lines +27 to +32
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Race condition may cause missed top 10 celebration.

The initial leaderboard fetch is asynchronous and runs concurrently with WebSocket setup. If a score_updated event arrives before this fetch completes, previousRankRef.current will still be null, causing the check at line 58 (oldRank !== null) to fail. This means the user won't see the top 10 celebration on their first ranking update.

Consider awaiting the initial rank fetch before establishing the WebSocket, or initializing previousRankRef to a safe default (e.g., Infinity) so the comparison works correctly:

Suggested approach
-  const previousRankRef = useRef<number | null>(null);
+  const previousRankRef = useRef<number>(Infinity);

And update the check accordingly:

-                    if (newRank <= 10 && oldRank !== null && oldRank > 10) {
+                    if (newRank <= 10 && oldRank > 10) {
🤖 Prompt for AI Agents
In @frontend/src/components/GamificationOverlay.tsx around lines 27 - 32, The
initial leaderboard fetch (fetchGamificationLeaderboard) can finish after the
WebSocket begins delivering score_updated events, leaving
previousRankRef.current null and preventing the first top-10 celebration; either
await the initial fetch before creating/connecting the WebSocket so
previousRankRef is populated before any events are handled, or initialize
previousRankRef.current to a safe default (e.g., Infinity) and update the
score_updated handler to use that sentinel (and compare using !== undefined/null
as appropriate) so the first rank delta is evaluated correctly; locate
references to fetchGamificationLeaderboard, previousRankRef, and the
score_updated WebSocket handler to implement the chosen fix.


if (wsRef.current) {
wsRef.current.close();
}

const ws = createGamificationWebSocket(
token,
async (event: GamificationEvent) => {
if (event.userId !== user.id) return;

if (event.type === "badge_awarded" && event.badgeName) {
setBadgeUnlocked({
badgeName: event.badgeName,
isOpen: true,
});
} else if (event.type === "score_updated") {
// Check for rank change
try {
const data = await fetchGamificationLeaderboard(token);
const myEntry = data.debaters.find(d => d.id === user.id);
if (myEntry) {
const newRank = myEntry.rank;
const oldRank = previousRankRef.current;

// Celebrate if entering top 10
if (newRank <= 10 && oldRank !== null && oldRank > 10) {
setBadgeUnlocked({
badgeName: "Top10",
isOpen: true,
});
}
previousRankRef.current = newRank;
}
} catch (e) {
console.error("Failed to check rank", e);
}
}
}
);

wsRef.current = ws;

return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, [user]);

return (
<BadgeUnlocked
badgeName={badgeUnlocked.badgeName}
isOpen={badgeUnlocked.isOpen}
onClose={() => setBadgeUnlocked({ ...badgeUnlocked, isOpen: false })}
/>
);
};

export default GamificationOverlay;
2 changes: 2 additions & 0 deletions frontend/src/components/SavedTranscripts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const SavedTranscripts: React.FC<SavedTranscriptsProps> = ({ className }) => {
case 'win':
return <Trophy className='w-4 h-4 text-yellow-500' />;
case 'loss':
case 'concede':
return <XCircle className='w-4 h-4 text-red-500' />;
case 'draw':
return <MinusCircle className='w-4 h-4 text-gray-500' />;
Expand All @@ -154,6 +155,7 @@ const SavedTranscripts: React.FC<SavedTranscriptsProps> = ({ className }) => {
case 'win':
return 'bg-green-100 text-green-800 border-green-200';
case 'loss':
case 'concede':
return 'bg-red-100 text-red-800 border-red-200';
case 'draw':
return 'bg-gray-100 text-gray-800 border-gray-200';
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/services/transcriptService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface SavedDebateTranscript {
debateType: 'user_vs_bot' | 'user_vs_user';
topic: string;
opponent: string;
result: 'win' | 'loss' | 'draw' | 'pending';
result: 'win' | 'loss' | 'draw' | 'pending' | 'concede';
messages: Array<{
sender: string;
text: string;
Expand Down