diff --git a/backend/.gitignore b/backend/.gitignore index 4c49bd7..aa3aef9 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,2 @@ .env +config.prod.yml diff --git a/backend/controllers/debatevsbot_controller.go b/backend/controllers/debatevsbot_controller.go index e7b3ae0..9571099 100644 --- a/backend/controllers/debatevsbot_controller.go +++ b/backend/controllers/debatevsbot_controller.go @@ -385,7 +385,7 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t return } - log.Printf("Successfully updated score. New score: %d (was %d, added %d)", + log.Printf("Successfully updated score. New score: %d (was %d, added %d)", updatedUser.Score, user.Score, pointsToAdd) // Save score update record @@ -418,11 +418,11 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t if resultStatus == "win" && !hasBadge["FirstWin"] { badgeUpdate := bson.M{"$addToSet": bson.M{"badges": "FirstWin"}} userCollection.UpdateOne(ctx, bson.M{"_id": userID}, badgeUpdate) - + // Update the updatedUser object to include the new badge updatedUser.Badges = append(updatedUser.Badges, "FirstWin") hasBadge["FirstWin"] = true - + // Save badge record badgeCollection := db.MongoDatabase.Collection("user_badges") userBadge := models.UserBadge{ @@ -460,7 +460,7 @@ func updateGamificationAfterBotDebate(userID primitive.ObjectID, resultStatus, t Timestamp: time.Now(), }) - log.Printf("Updated gamification for user %s: +%d points (new score: %d), result: %s", + log.Printf("Updated gamification for user %s: +%d points (new score: %d), result: %s", userID.Hex(), pointsToAdd, updatedUser.Score, resultStatus) } @@ -514,3 +514,96 @@ func checkAndAwardAutomaticBadges(ctx context.Context, userID primitive.ObjectID // Check for Debater10 badge (10 debates completed) // Note: This would require tracking debate count, which might need to be added } + +type ConcedeRequest struct { + DebateId string `json:"debateId" binding:"required"` + History []models.Message `json:"history"` +} + +func ConcedeDebate(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + c.JSON(401, gin.H{"error": "Authorization token required"}) + return + } + + token = strings.TrimPrefix(token, "Bearer ") + valid, email, err := utils.ValidateTokenAndFetchEmail("./config/config.prod.yml", token, c) + if err != nil || !valid { + c.JSON(401, gin.H{"error": "Invalid or expired token"}) + return + } + + var req ConcedeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "Invalid request payload: " + err.Error()}) + return + } + + objID, err := primitive.ObjectIDFromHex(req.DebateId) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid debate ID"}) + return + } + + // Fetch the debate to get details + var debate models.DebateVsBot + err = db.DebateVsBotCollection.FindOne(context.Background(), bson.M{"_id": objID}).Decode(&debate) + if err != nil { + c.JSON(404, gin.H{"error": "Debate not found"}) + return + } + + // Update debate outcome + filter := bson.M{"_id": objID} + update := bson.M{"$set": bson.M{"outcome": "User conceded"}} + + _, err = db.DebateVsBotCollection.UpdateOne(context.Background(), filter, update) + if err != nil { + c.JSON(500, gin.H{"error": "Failed to update debate: " + err.Error()}) + return + } + + // Get user ID from email + userCollection := db.GetCollection("users") + var user models.User + err = userCollection.FindOne(context.Background(), bson.M{"email": email}).Decode(&user) + if err != nil { + // Log error but don't fail the request as the main action succeeded + log.Printf("Error finding user for concede updates: %v", err) + c.JSON(200, gin.H{"message": "Debate conceded successfully"}) + return + } + + // Save transcript to history + // We treat concession as a loss + // Use history from request if available, otherwise fall back to debate record history + historyToSave := debate.History + if len(req.History) > 0 { + historyToSave = req.History + } + + _ = services.SaveDebateTranscript( + user.ID, + email, + "user_vs_bot", + debate.Topic, + debate.BotName, + "loss", + historyToSave, + nil, + ) + + // Update gamification (score, badges, streaks) + // Call synchronously but with recover to prevent panics + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic in updateGamificationAfterBotDebate (concede): %v", r) + } + }() + updateGamificationAfterBotDebate(user.ID, "loss", debate.Topic) + }() + + c.JSON(200, gin.H{"message": "Debate conceded successfully"}) +} diff --git a/backend/routes/debatevsbot.go b/backend/routes/debatevsbot.go index 9e42541..80888c2 100644 --- a/backend/routes/debatevsbot.go +++ b/backend/routes/debatevsbot.go @@ -13,5 +13,6 @@ func SetupDebateVsBotRoutes(router *gin.RouterGroup) { vsbot.POST("/create", controllers.CreateDebate) vsbot.POST("/debate", controllers.SendDebateMessage) vsbot.POST("/judge", controllers.JudgeDebate) + vsbot.POST("/concede", controllers.ConcedeDebate) } } diff --git a/backend/websocket/websocket.go b/backend/websocket/websocket.go index 80ca7ad..bae9351 100644 --- a/backend/websocket/websocket.go +++ b/backend/websocket/websocket.go @@ -11,7 +11,9 @@ import ( "time" "arguehub/db" + "arguehub/services" "arguehub/utils" + "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -470,6 +472,8 @@ func WebsocketHandler(c *gin.Context) { handleMuteRequest(room, conn, message, client, roomID) case "unmute": handleUnmuteRequest(room, conn, message, client, roomID) + case "concede": + handleConcede(room, conn, message, client, roomID) default: if message.Type == "requestOffer" && client.IsSpectator { var req map[string]interface{} @@ -750,3 +754,43 @@ func getUserDetails(email string) (string, string, string, int, error) { rating := int(math.Round(user.Rating)) return user.ID.Hex(), user.DisplayName, user.AvatarURL, rating, nil } + +// handleConcede handles concede requests +func handleConcede(room *Room, conn *websocket.Conn, message Message, client *Client, roomID string) { + // Broadcast concede message to all clients (including spectators) + broadcastMessage := Message{ + Type: "concede", + Room: roomID, + Username: client.Username, + UserID: client.UserID, + Content: "User conceded the debate", + } + + // Send to all clients + for _, r := range snapshotRecipients(room, nil) { + r.SafeWriteJSON(broadcastMessage) + } + + // Find opponent + var opponent *Client + room.Mutex.Lock() + for _, c := range room.Clients { + if !c.IsSpectator && c.UserID != client.UserID { + opponent = c + break + } + } + room.Mutex.Unlock() + + if opponent != nil { + // Update ratings + // User lost (0.0), Opponent won (1.0) + userID, _ := primitive.ObjectIDFromHex(client.UserID) + opponentID, _ := primitive.ObjectIDFromHex(opponent.UserID) + + _, _, err := services.UpdateRatings(userID, opponentID, 0.0, time.Now()) + if err != nil { + log.Printf("Error updating ratings after concede: %v", err) + } + } +} diff --git a/frontend/src/Pages/DebateRoom.tsx b/frontend/src/Pages/DebateRoom.tsx index 5581141..42faf2d 100644 --- a/frontend/src/Pages/DebateRoom.tsx +++ b/frontend/src/Pages/DebateRoom.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect, useRef } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; -import { sendDebateMessage, judgeDebate } from "@/services/vsbot"; +import { sendDebateMessage, judgeDebate, concedeDebate } from "@/services/vsbot"; import JudgmentPopup from "@/components/JudgementPopup"; import { Mic, MicOff } from "lucide-react"; import { useAtom } from "jotai"; @@ -218,6 +218,7 @@ const extractJSON = (response: string): string => { const DebateRoom: React.FC = () => { const location = useLocation(); + const navigate = useNavigate(); const debateData = location.state as DebateProps; const phases = debateData.phaseTimings; const debateKey = `debate_${debateData.userId}_${debateData.topic}_${debateData.debateId}`; @@ -258,6 +259,30 @@ const DebateRoom: React.FC = () => { const userAvatar = user?.avatarUrl || "https://avatar.iran.liara.run/public/10"; + const handleConcede = async () => { + if (window.confirm("Are you sure you want to concede? This will count as a loss.")) { + try { + if (debateData.debateId) { + await concedeDebate(debateData.debateId, state.messages); + } + + setState(prev => ({ ...prev, isDebateEnded: true })); + setPopup({ + show: true, + message: "You have conceded the debate.", + isJudging: false + }); + + setTimeout(() => { + navigate("/game"); + }, 2000); + + } catch (error) { + console.error("Error conceding:", error); + } + } + }; + // Initialize SpeechRecognition useEffect(() => { if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) { @@ -800,6 +825,14 @@ const DebateRoom: React.FC = () => { {user?.rating ? `Rating: ${user.rating}` : "Ready to argue!"} + {!state.isDebateEnded && ( + + )}

diff --git a/frontend/src/Pages/OnlineDebateRoom.tsx b/frontend/src/Pages/OnlineDebateRoom.tsx index 513ddd7..613c5df 100644 --- a/frontend/src/Pages/OnlineDebateRoom.tsx +++ b/frontend/src/Pages/OnlineDebateRoom.tsx @@ -797,6 +797,25 @@ const OnlineDebateRoom = (): JSX.Element => { startJudgmentPolling, ]); + const handleConcede = useCallback(() => { + if (window.confirm("Are you sure you want to concede? This will count as a loss.")) { + if (wsRef.current) { + wsRef.current.send(JSON.stringify({ + type: "concede", + room: roomId, + userId: currentUserId, + username: currentUser?.displayName || "User" + })); + } + setDebatePhase(DebatePhase.Finished); + setPopup({ + show: true, + message: "You have conceded the debate.", + isJudging: false, + }); + } + }, [roomId, currentUserId, currentUser, setDebatePhase, setPopup]); + const handlePhaseDone = useCallback(() => { const currentIndex = phaseOrder.indexOf(debatePhase); console.debug( @@ -1241,6 +1260,14 @@ const OnlineDebateRoom = (): JSX.Element => { } } break; + case "concede": + setDebatePhase(DebatePhase.Finished); + setPopup({ + show: true, + message: `${data.username || "Opponent"} has conceded the debate. You win!`, + isJudging: false, + }); + break; case "spectatorJoined": if (data.spectator?.connectionId) { queueSpectatorOffer( @@ -2090,6 +2117,16 @@ const OnlineDebateRoom = (): JSX.Element => { )}

+ {debatePhase !== DebatePhase.Finished && debatePhase !== DebatePhase.Setup && ( +
+ +
+ )}
diff --git a/frontend/src/services/vsbot.ts b/frontend/src/services/vsbot.ts index 38160c7..0e48f82 100644 --- a/frontend/src/services/vsbot.ts +++ b/frontend/src/services/vsbot.ts @@ -100,6 +100,23 @@ export const sendDebateMessage = async (data: DebateRequest): Promise<{ response return { response: result.response }; // Adjusted to return bot's response directly }; +export const concedeDebate = async (debateId: string, history: DebateMessage[] = []): Promise => { + const token = getAuthToken(); + const response = await fetch(`${baseURL}/vsbot/concede`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + credentials: "include", + body: JSON.stringify({ debateId, history }), + }); + + if (!response.ok) { + throw new Error("Failed to concede debate"); + } +}; + // Function to judge a debate export const judgeDebate = async (data: JudgeRequest): Promise => { const token = getAuthToken();