/* eslint-disable @typescript-eslint/no-unused-vars */ import type * as Party from 'partykit/server'; import { createClerkClient, verifyToken } from '@clerk/backend'; export interface Upgrade { id: string; name: string; description: string; baseCost: number; multiplier: number; clickBonus?: number; autoClickRate?: number; clickMultiplierBonus?: number; icon: string; mascotTiers?: MascotTier[]; oneTime?: boolean; newsTitles?: string[]; youtubeId?: string; } export interface MascotTier { level: number; imageSrc: string; multiplier: number; rarity: number; } interface UserState { name: string; clicks: number; lastSeen: number; bonusMultiplier: number; lastClickTime?: number; upgrades: Record; clickMultiplier: number; autoClickRate: number; ratPoisonChallenge?: { challengeString: string; expiresAt: number; }; ratPoisonImmunityUntil?: number; } interface GameState { totalClicks: number; users: Record; milestones: Record; currentBackground: string; currentClickImage: string; } interface AuthenticatedMessage { token: string; userId: string; // Expected userId from client userName: string; // Expected userName from client } interface ClickMessage extends AuthenticatedMessage { type: 'click'; } interface PurchaseUpgradeMessage extends AuthenticatedMessage { type: 'purchase-upgrade'; upgradeId: string; } interface ApplyMultiplierBonusMessage extends AuthenticatedMessage { // New message type type: 'apply-multiplier-bonus'; multiplierBonus: number; } interface AdminBroadcastMessage extends AuthenticatedMessage { type: 'admin-broadcast'; message: string; targetUserId?: string; // Optional: if broadcasting to a specific user } interface UserJoinMessage extends AuthenticatedMessage { type: 'user-join'; } interface EditUserMessage extends AuthenticatedMessage { type: 'edit-user'; targetUserId: string; clicks: number; upgrades: Record; } interface ThrowRatPoisonMessage extends AuthenticatedMessage { type: 'throw-rat-poison'; targetUserId: string; } interface SolveRatPoisonMessage extends AuthenticatedMessage { type: 'solve-rat-poison'; challengeString: string; } type Message = ClickMessage | PurchaseUpgradeMessage | ApplyMultiplierBonusMessage | UserJoinMessage | AdminBroadcastMessage | EditUserMessage | ThrowRatPoisonMessage | SolveRatPoisonMessage; // Updated Message type import { UPGRADES as ALL_UPGRADES } from '../src/config/upgrades'; const UPGRADES = ALL_UPGRADES.filter(upgrade => !upgrade.oneTime); const ONE_TIME_UPGRADES = ALL_UPGRADES.filter(upgrade => upgrade.oneTime); const MILESTONES = [ { threshold: 1000, id: 'first-thousand', background: 'rainbow', image: 'https://media1.tenor.com/m/x8v1oNUOmg4AAAAd/spinning-rat-rat.gif' }, { threshold: 10000, id: 'ten-thousand', background: 'cyberpunk', image: 'https://cdn.discordapp.com/emojis/1297946545626288312.webp' }, { threshold: 50000, id: 'epic-milestone', background: 'space', image: 'https://media.discordapp.net/stickers/1377379131649429554.gif?size=160&quality=lossless' }, { threshold: 100000, id: 'legendary', background: 'musume', image: 'https://media1.tenor.com/m/pV74fmh_NLgAAAAd/louie-rat-spinning-rat.gif' }, { threshold: 200000, id: 'two-hundred-thousand', background: 'glitch', image: 'https://media1.tenor.com/m/pV74fmh_NLgAAAAd/louie-rat-spinning-rat.gif' }, { threshold: 696969, id: 'ultimate', background: 'ultimate', image: 'https://media.discordapp.net/stickers/1397981135266648064.webp?size=160&quality=lossless' }, { threshold: 1000000, id: 'god-tier', background: 'god-tier', image: 'https://media.discordapp.net/stickers/1294812453112123453.webp?quality=lossless' }, { threshold: 5000000, id: 'mega-bozo', background: 'mega-bozo', image: 'https://media1.tenor.com/m/x8v1oNUOmg4AAAAd/spinning-rat-rat.gif' }, { threshold: 8008008, id: 'galactic-bozo', background: 'galactic-bozo', image: 'https://cdn.discordapp.com/emojis/1297946545626288312.webp' }, { threshold: 10000000, id: 'cosmic-bozo', background: 'cosmic-bozo', image: 'https://media.discordapp.net/stickers/1377379131649429554.gif?size=160&quality=lossless' }, { threshold: 100000000, id: 'interdimensional-bozo', background: 'interdimensional', image: 'https://tenor.com/view/horizontally-spinning-rat-gif-2222021229431992839.gif' }, { threshold: 500000000, id: 'multiversal-bozo', background: 'multiversal', image: 'https://tenor.com/view/spinning-rat-gif-26132.gif' }, { threshold: 1000000000, id: 'billionaire-bozo', background: 'billionaire', image: 'https://tenor.com/view/dancing-rat-rat-dance-gif-25151223.gif' }, { threshold: 10000000000, id: 'ten-billion-bozo', background: 'ten-billion', image: 'https://tenor.com/view/horizontally-spinning-rat-gif-2222021229431992839.gif' }, { threshold: 100000000000, id: 'hundred-billion-bozo', background: 'hundred-billion', image: 'https://tenor.com/view/spinning-rat-gif-26132.gif' }, { threshold: 500000000000, id: 'half-trillion-bozo', background: 'half-trillion', image: 'https://tenor.com/view/dancing-rat-rat-dance-gif-25151223.gif' }, { threshold: 1000000000000, id: 'trillionaire-bozo', background: 'trillionaire', image: 'https://tenor.com/view/dancing-rat-rat-dance-gif-25151223.gif' } ]; export default class GameServer implements Party.Server { clerkClient: ReturnType; private userConnections: Map = new Map(); // Map userId to connection constructor(readonly party: Party.Party) { this.clerkClient = createClerkClient({ secretKey: party.env.CLERK_SECRET_KEY as string, }); } gameState: GameState = { totalClicks: 0, users: {}, milestones: {}, currentBackground: 'default', currentClickImage: 'https://media1.tenor.com/m/pV74fmh_NLgAAAAd/louie-rat-spinning-rat.gif' }; autoClickInterval?: NodeJS.Timeout; async onConnect(conn: Party.Connection, _ctx: Party.ConnectionContext) { conn.send(JSON.stringify({ type: 'game-state', state: this.gameState })); } onClose(conn: Party.Connection) { // Remove the connection from the map when it closes for (const [userId, connection] of this.userConnections.entries()) { if (connection === conn) { this.userConnections.delete(userId); break; } } } async onMessage(message: string, sender: Party.Connection) { const data = JSON.parse(message) as Message; let currentUserId: string; let currentUserName: string; let isAuthenticated = false; // Verify the Clerk token for authenticated messages if ('token' in data && data.token) { try { const sessionClaims = await verifyToken(data.token, { jwtKey: this.party.env.CLERK_JWT_KEY as string, }); currentUserId = sessionClaims.sub; isAuthenticated = true; // Ensure the userId from the client matches the authenticated userId if (currentUserId !== data.userId) { console.warn(`User ID mismatch: Client sent ${data.userId}, but token is for ${currentUserId}`); sender.close(); // Close connection for potential tampering return; } currentUserName = data.userName; // Use name from client, which comes from Clerk } catch (error) { console.error('Clerk token verification failed:', error); sender.close(); // Close connection if token is invalid return; } } else { // Handle unauthenticated messages (guest users) currentUserId = data.userId; currentUserName = data.userName; isAuthenticated = false; // Only allow 'user-join' for unauthenticated users. // Other actions like 'click', 'purchase-upgrade', 'apply-multiplier-bonus' are ignored. if (data.type !== 'user-join') { console.warn(`Received unauthenticated message of type '${data.type}' from ${currentUserId}. Action ignored.`); this.broadcast(); // Still broadcast game state to keep guests updated return; } } // Only update user info in gameState for authenticated users if (isAuthenticated) { if (!this.gameState.users[currentUserId]) { this.gameState.users[currentUserId] = { name: currentUserName, clicks: 0, lastSeen: Date.now(), bonusMultiplier: 1, // Initialize bonus multiplier upgrades: ALL_UPGRADES.reduce((acc, upgrade) => { acc[upgrade.id] = { owned: 0, cost: upgrade.baseCost }; return acc; }, {} as Record), clickMultiplier: 1, autoClickRate: 0, ratPoisonChallenge: undefined, ratPoisonImmunityUntil: undefined, }; } this.gameState.users[currentUserId].lastSeen = Date.now(); this.gameState.users[currentUserId].name = currentUserName; // Update name in case it changed // Ensure bonusMultiplier is initialized if it somehow wasn't if (this.gameState.users[currentUserId].bonusMultiplier === undefined) { this.gameState.users[currentUserId].bonusMultiplier = 1; } // Store the connection for targeted broadcasts this.userConnections.set(currentUserId, sender); } switch (data.type) { case 'user-join': // For authenticated users, user join is handled by the user state update above. // For guest users, no action is needed here as they are not added to gameState.users. break; case 'click': if (isAuthenticated) { this.handleClick(data, currentUserId); } else { console.warn(`Unauthenticated click from ${currentUserId} ignored.`); } break; case 'purchase-upgrade': if (isAuthenticated) { this.handlePurchaseUpgrade(data, currentUserId); } else { console.warn(`Unauthenticated purchase-upgrade from ${currentUserId} ignored.`); } break; case 'apply-multiplier-bonus': if (isAuthenticated) { this.handleApplyMultiplierBonus(data, currentUserId); } else { console.warn(`Unauthenticated apply-multiplier-bonus from ${currentUserId} ignored.`); } break; case 'admin-broadcast': if (isAuthenticated && currentUserId === this.party.env.CLERK_ADMIN_USERID) { this.handleAdminBroadcast(data); } else { console.warn(`Unauthorized admin broadcast attempt from ${currentUserId}.`); } break; case 'edit-user': if (isAuthenticated && currentUserId === this.party.env.CLERK_ADMIN_USERID) { this.handleEditUser(data); } else { console.warn(`Unauthorized edit user attempt from ${currentUserId}.`); } break; case 'throw-rat-poison': if (isAuthenticated) { this.handleThrowRatPoison(data, currentUserId); } else { console.warn(`Unauthenticated throw-rat-poison from ${currentUserId} ignored.`); } break; case 'solve-rat-poison': if (isAuthenticated) { this.handleSolveRatPoison(data, currentUserId); } else { console.warn(`Unauthenticated solve-rat-poison from ${currentUserId} ignored.`); } break; } this.broadcast(); } handleEditUser(data: EditUserMessage) { const { targetUserId, clicks, upgrades } = data; const userState = this.gameState.users[targetUserId]; if (userState) { userState.clicks = clicks; userState.upgrades = upgrades; this.updateGameMultipliers(targetUserId); } } handleThrowRatPoison(data: ThrowRatPoisonMessage, authenticatedUserId: string) { const { targetUserId } = data; const targetUserState = this.gameState.users[targetUserId]; const attackerUserState = this.gameState.users[authenticatedUserId]; if (!targetUserState || !attackerUserState || targetUserId === authenticatedUserId) { return; // Cannot poison self or non-existent user } const now = Date.now(); if (targetUserState.ratPoisonImmunityUntil && targetUserState.ratPoisonImmunityUntil > now) { // Target is immune, notify attacker const attackerConn = this.userConnections.get(authenticatedUserId); if (attackerConn) { attackerConn.send(JSON.stringify({ type: 'rat-poison-feedback', message: `${targetUserState.name} is currently immune to rat poison!` })); } return; } // Get news headlines for challenge string const newsUpgrade = ALL_UPGRADES.find(u => u.id === 'news'); const newsTitles = newsUpgrade?.newsTitles || []; if (newsTitles.length === 0) { console.warn('No news titles found for rat poison challenge.'); return; } const challengeString = newsTitles[Math.floor(Math.random() * newsTitles.length)]; const challengeDuration = 10 * 1000; // 10 seconds to solve const expiresAt = now + challengeDuration; targetUserState.ratPoisonChallenge = { challengeString, expiresAt, }; // Notify target user to start challenge const targetConn = this.userConnections.get(targetUserId); if (targetConn) { targetConn.send(JSON.stringify({ type: 'rat-poison-challenge', challenge: challengeString, expiresAt, })); } // Set a timeout for the challenge to expire if not solved setTimeout(() => { if (targetUserState.ratPoisonChallenge && targetUserState.ratPoisonChallenge.expiresAt === expiresAt) { // Challenge not solved in time this.applyRatPoisonPenalty(targetUserId); this.broadcast(); } }, challengeDuration + 500); // Give a small buffer this.broadcast(); } handleSolveRatPoison(data: SolveRatPoisonMessage, authenticatedUserId: string) { const userState = this.gameState.users[authenticatedUserId]; if (!userState || !userState.ratPoisonChallenge) { return; // No active challenge } const now = Date.now(); if (now > userState.ratPoisonChallenge.expiresAt) { // Challenge expired this.applyRatPoisonPenalty(authenticatedUserId); } else if (data.challengeString === userState.ratPoisonChallenge.challengeString) { // Challenge solved successfully userState.ratPoisonImmunityUntil = now + (2 * 60 * 1000); // 2 minutes immunity const userConn = this.userConnections.get(authenticatedUserId); if (userConn) { userConn.send(JSON.stringify({ type: 'rat-poison-result', success: true, message: 'Challenge solved! You are immune for 2 minutes.' })); } } else { // Challenge failed (wrong string) this.applyRatPoisonPenalty(authenticatedUserId); const userConn = this.userConnections.get(authenticatedUserId); if (userConn) { userConn.send(JSON.stringify({ type: 'rat-poison-result', success: false, message: 'Incorrect string! You lost clicks and are immune for 20 seconds.' })); } } userState.ratPoisonChallenge = undefined; // Clear the challenge this.broadcast(); } applyRatPoisonPenalty(userId: string) { const userState = this.gameState.users[userId]; if (!userState) return; const clicksLost = Math.floor(userState.clicks * 0.30); userState.clicks -= clicksLost; userState.ratPoisonImmunityUntil = Date.now() + (20 * 1000); // 20 seconds immunity const userConn = this.userConnections.get(userId); if (userConn) { userConn.send(JSON.stringify({ type: 'rat-poison-result', success: false, message: `You failed the challenge and lost ${clicksLost} clicks! You are immune for 20 seconds.` })); } } // Define a minimum interval between clicks (e.g., 100ms for 10 clicks/second) // This helps prevent simple auto-clicker scripts by rate-limiting server-side. private readonly MIN_CLICK_INTERVAL = 50; // milliseconds // handleUserJoin is now fully integrated into onMessage and can be removed or simplified. // Removing it as its logic is now directly in onMessage. // handleUserJoin(data: UserJoinMessage) { // // This function is now mostly handled by the onMessage token verification logic // // It ensures the user exists in gameState.users and updates lastSeen/name. // // No explicit action needed here beyond what onMessage does. // } handleClick(data: ClickMessage, authenticatedUserId: string) { const now = Date.now(); const userState = this.gameState.users[authenticatedUserId]; // Ensure user exists (should be handled by onMessage's token verification) if (!userState) { console.warn(`User ${authenticatedUserId} not found for click handling.`); return; } // Implement rate limiting if (userState.lastClickTime && (now - userState.lastClickTime < this.MIN_CLICK_INTERVAL)) { console.warn(`Rate limit exceeded for user ${authenticatedUserId}. Click ignored.`); return; } // Apply global click multiplier and user-specific bonus multiplier const userBonusMultiplier = userState.bonusMultiplier || 1; const clickValue = userState.clickMultiplier * userBonusMultiplier; this.gameState.totalClicks += clickValue; userState.clicks += clickValue; userState.lastSeen = now; userState.lastClickTime = now; // Update last valid click time this.checkMilestones(); } handleAdminBroadcast(data: AdminBroadcastMessage) { const broadcastPayload = { type: 'admin-message', message: data.message, sender: 'Admin' }; if (data.targetUserId) { // Send to a specific user using the stored connection const targetConn = this.userConnections.get(data.targetUserId); if (targetConn) { targetConn.send(JSON.stringify(broadcastPayload)); console.log(`Admin broadcasted to user ${data.targetUserId}: ${data.message}`); } else { console.warn(`Target user ${data.targetUserId} not found or not connected.`); } } else { // Broadcast to all connections this.party.broadcast(JSON.stringify(broadcastPayload)); console.log(`Admin broadcasted to all users: ${data.message}`); } } handleApplyMultiplierBonus(data: ApplyMultiplierBonusMessage, authenticatedUserId: string) { if (!this.gameState.users[authenticatedUserId]) { console.warn(`User ${authenticatedUserId} not found for multiplier bonus application.`); return; } // Apply the compounding multiplier bonus this.gameState.users[authenticatedUserId].bonusMultiplier *= data.multiplierBonus; console.log(`User ${authenticatedUserId} bonus multiplier updated to: ${this.gameState.users[authenticatedUserId].bonusMultiplier}`); } handlePurchaseUpgrade(data: PurchaseUpgradeMessage, authenticatedUserId: string) { const upgradeConfig = UPGRADES.find(u => u.id === data.upgradeId); const userState = this.gameState.users[authenticatedUserId]; if (!upgradeConfig || !userState) return; const currentUpgradeState = userState.upgrades[data.upgradeId]; // Prevent purchasing one-time upgrades if already owned if (upgradeConfig.oneTime && currentUpgradeState.owned > 0) { console.warn(`Attempted to re-purchase one-time upgrade: ${data.upgradeId}`); return; } // Check affordability against user's clicks if (userState.clicks >= currentUpgradeState.cost) { userState.clicks -= currentUpgradeState.cost; // Deduct from user's clicks currentUpgradeState.owned += 1; // For one-time upgrades, cost doesn't change after first purchase if (!upgradeConfig.oneTime) { currentUpgradeState.cost = Math.floor(upgradeConfig.baseCost * Math.pow(upgradeConfig.multiplier, currentUpgradeState.owned)); } this.updateGameMultipliers(authenticatedUserId); } } updateGameMultipliers(userId: string, autoClickRateOverride?: number) { const userState = this.gameState.users[userId]; if (!userState) return; userState.clickMultiplier = 1; userState.autoClickRate = 0; Object.entries(userState.upgrades).forEach(([upgradeId, upgradeState]) => { const config = UPGRADES.find(u => u.id === upgradeId); if (!config) return; if (config.clickBonus) { userState.clickMultiplier += config.clickBonus * upgradeState.owned; } if (config.autoClickRate) { userState.autoClickRate += config.autoClickRate * upgradeState.owned; } }); if (autoClickRateOverride !== undefined) { userState.autoClickRate = autoClickRateOverride; } this.setupAutoClicker(); } setupAutoClicker() { if (this.autoClickInterval) { clearInterval(this.autoClickInterval); } this.autoClickInterval = setInterval(() => { let totalAutoClicks = 0; Object.values(this.gameState.users).forEach(user => { if (user.autoClickRate > 0) { user.clicks += user.autoClickRate; totalAutoClicks += user.autoClickRate; } }); if (totalAutoClicks > 0) { this.gameState.totalClicks += totalAutoClicks; this.checkMilestones(); this.broadcast(); } }, 1000); } checkMilestones() { MILESTONES.forEach(milestone => { if (this.gameState.totalClicks >= milestone.threshold && !this.gameState.milestones[milestone.id]) { this.gameState.milestones[milestone.id] = true; this.gameState.currentBackground = milestone.background; this.gameState.currentClickImage = milestone.image; } }); } broadcast() { this.party.broadcast(JSON.stringify({ type: 'game-state', state: this.gameState })); } } GameServer satisfies Party.Worker;