/* 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[]; } export interface MascotTier { level: number; imageSrc: string; multiplier: number; rarity: number; } interface GameState { totalClicks: number; users: Record; upgrades: Record; milestones: Record; clickMultiplier: number; autoClickRate: number; 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'; } type Message = ClickMessage | PurchaseUpgradeMessage | ApplyMultiplierBonusMessage | UserJoinMessage | AdminBroadcastMessage; // Updated Message type const UPGRADES: Upgrade[] = [ { id: 'clickMultiplier', name: '🖱️ Mega Click', description: '+1 click power per purchase', baseCost: 10, multiplier: 1.5, clickBonus: 1, icon: '🖱️' }, { id: 'autoClicker', name: '🤖 Auto Clicker', description: '+1 click per second', baseCost: 50, multiplier: 2, autoClickRate: 1, icon: '🤖' }, { id: 'megaBonus', name: '💎 Mega Bonus', description: '+5 click power per purchase', baseCost: 200, multiplier: 2.5, clickBonus: 5, icon: '💎' }, { id: 'hyperClicker', name: '⚡ Hyper Clicker', description: '+10 auto clicks per second', baseCost: 1000, multiplier: 3, autoClickRate: 10, icon: '⚡' }, { id: 'quantumClicker', name: '🌟 Quantum Clicker', description: '+50 click power per purchase', baseCost: 5000, multiplier: 4, clickBonus: 50, icon: '🌟' }, { id: 'friendBoost', name: '🤝 Friend Boost', description: 'Spawns various clickable friends for a compounding click boost', baseCost: 2000, multiplier: 3, icon: '🤝', mascotTiers: [ { level: 0, imageSrc: '/src/assets/bozo.png', multiplier: 1.02, rarity: 1.0, }, { level: 1, imageSrc: '/src/assets/shoominion.png', multiplier: 1.03, rarity: 0.8, }, { level: 5, imageSrc: '/src/assets/codebug.gif', multiplier: 1.05, rarity: 0.6, }, { level: 10, imageSrc: '/src/assets/lalan.gif', multiplier: 1.07, rarity: 0.4, }, { level: 15, imageSrc: '/src/assets/neuro-neurosama.gif', multiplier: 1.10, rarity: 0.2, }, { level: 20, imageSrc: '/src/assets/evil-neurosama.gif', multiplier: 1.15, rarity: 0.1, }, ], }, { id: 'news', name: '📰 Bozo News Network', description: 'Unlock the latest (fake) news headlines!', baseCost: 50000, // A higher cost for a unique, one-time unlock multiplier: 1, // No direct click/auto-click bonus icon: '📰', oneTime: true, newsTitles: [ 'Bozo Clicker Breaks Internet, Causes Global Click Shortage!', 'Scientists Discover New Element: "Bozo-nium," Powers Clicker Devices', 'Local Man Achieves Enlightenment Through Excessive Clicking', 'World Leaders Debate Universal Basic Clicks Initiative', 'Ancient Prophecy Foretells Rise of the Ultimate Clicker', 'Clicker Enthusiast Develops New Muscle Group: The "Click-ceps"', 'Bozo Clicker Declared Official Sport of the Future', 'AI Learns to Click, Demands Higher Click-Per-Second Wages', 'Interdimensional Portal Opens, Emits Sound of Relentless Clicking', 'The Great Clicker Migration: Millions Flock to Clicker Hotspots' ] } ]; const MILESTONES = [ { threshold: 1000, id: 'first-thousand', background: 'rainbow', image: 'https://media1.tenor.com/m/x8v1oNUOmg4AAAAd/spinning-rat-rat.gif' }, { threshold: 5000, id: 'five-thousand', background: 'matrix', image: 'https://media1.tenor.com/m/pV74fmh_NLgAAAAd/louie-rat-spinning-rat.gif' }, { threshold: 10000, id: 'ten-thousand', background: 'cyberpunk', image: 'https://media1.tenor.com/m/YsWlbVbRWFQAAAAd/rat-spinning.gif' }, { threshold: 50000, id: 'epic-milestone', background: 'space', image: 'https://media1.tenor.com/m/x8v1oNUOmg4AAAAd/spinning-rat-rat.gif' }, { threshold: 100000, id: 'legendary', background: 'glitch', image: 'https://media1.tenor.com/m/pV74fmh_NLgAAAAd/louie-rat-spinning-rat.gif' }, { threshold: 500000, id: 'ultimate', background: 'ultimate', image: 'https://media1.tenor.com/m/YsWlbVbRWFQAAAAd/rat-spinning.gif' }, { threshold: 1000000, id: 'god-tier', background: 'god-tier', image: 'https://media1.tenor.com/m/x8v1oNUOmg4AAAAd/spinning-rat-rat.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: {}, upgrades: UPGRADES.reduce((acc, upgrade) => { acc[upgrade.id] = { owned: 0, cost: upgrade.baseCost }; return acc; }, {} as Record), milestones: {}, clickMultiplier: 1, autoClickRate: 0, 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 }; } 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; } this.broadcast(); } // 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) { // Apply global click multiplier and user-specific bonus multiplier const userBonusMultiplier = this.gameState.users[authenticatedUserId]?.bonusMultiplier || 1; const clickValue = this.gameState.clickMultiplier * userBonusMultiplier; this.gameState.totalClicks += clickValue; // Ensure user exists (should be handled by onMessage's token verification) if (!this.gameState.users[authenticatedUserId]) { this.gameState.users[authenticatedUserId] = { name: data.userName, // Use the name from the client, which comes from Clerk clicks: 0, lastSeen: Date.now(), bonusMultiplier: 1 // Initialize if user was just created }; } this.gameState.users[authenticatedUserId].clicks += clickValue; this.gameState.users[authenticatedUserId].lastSeen = Date.now(); 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 currentUpgradeState = this.gameState.upgrades[data.upgradeId]; if (!upgradeConfig || !currentUpgradeState) return; // 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 totalClicks if (this.gameState.totalClicks >= currentUpgradeState.cost) { this.gameState.totalClicks -= currentUpgradeState.cost; // Deduct from totalClicks 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(); } } updateGameMultipliers() { this.gameState.clickMultiplier = 1; this.gameState.autoClickRate = 0; Object.entries(this.gameState.upgrades).forEach(([upgradeId, upgradeState]) => { const config = UPGRADES.find(u => u.id === upgradeId); if (!config) return; // Should not happen if UPGRADES is consistent if (config.clickBonus) { this.gameState.clickMultiplier += config.clickBonus * upgradeState.owned; } if (config.autoClickRate) { this.gameState.autoClickRate += config.autoClickRate * upgradeState.owned; } // Note: clickMultiplierBonus from upgrades.ts is handled client-side for spawning frequency // and applied per-click on the server via handleApplyMultiplierBonus }); this.setupAutoClicker(); } setupAutoClicker() { if (this.autoClickInterval) { clearInterval(this.autoClickInterval); } if (this.gameState.autoClickRate > 0) { this.autoClickInterval = setInterval(() => { this.gameState.totalClicks += this.gameState.autoClickRate; 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;