/* eslint-disable @typescript-eslint/no-unused-vars */ import type * as Party from 'partykit/server'; import { createClerkClient, verifyToken } from '@clerk/backend'; 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 UserJoinMessage extends AuthenticatedMessage { type: 'user-join'; } type Message = ClickMessage | PurchaseUpgradeMessage | UserJoinMessage; const UPGRADES = { clickMultiplier: { baseCost: 10, multiplier: 1.5, clickBonus: 1 }, autoClicker: { baseCost: 50, multiplier: 2, autoClickRate: 1 }, megaBonus: { baseCost: 200, multiplier: 2.5, clickBonus: 5 }, hyperClicker: { baseCost: 1000, multiplier: 3, autoClickRate: 10 }, quantumClicker: { baseCost: 5000, multiplier: 4, clickBonus: 50 } }; const MILESTONES = [ { threshold: 100, id: 'first-hundred', background: 'rainbow', image: 'https://media1.tenor.com/m/x8v1oNUOmg4AAAAd/spinning-rat-rat.gif' }, { threshold: 500, id: 'five-hundred', background: 'matrix', image: 'https://media1.tenor.com/m/pV74fmh_NLgAAAAd/louie-rat-spinning-rat.gif' }, { threshold: 1000, id: 'one-thousand', background: 'cyberpunk', image: 'https://media1.tenor.com/m/YsWlbVbRWFQAAAAd/rat-spinning.gif' }, { threshold: 2500, id: 'epic-milestone', background: 'space', image: 'https://media1.tenor.com/m/x8v1oNUOmg4AAAAd/spinning-rat-rat.gif' }, { threshold: 5000, id: 'legendary', background: 'glitch', image: 'https://media1.tenor.com/m/pV74fmh_NLgAAAAd/louie-rat-spinning-rat.gif' }, { threshold: 10000, id: 'ultimate', background: 'ultimate', image: 'https://media1.tenor.com/m/YsWlbVbRWFQAAAAd/rat-spinning.gif' } ]; export default class GameServer implements Party.Server { clerkClient: ReturnType; constructor(readonly party: Party.Party) { this.clerkClient = createClerkClient({ secretKey: party.env.CLERK_SECRET_KEY as string, }); } gameState: GameState = { totalClicks: 0, users: {}, upgrades: Object.keys(UPGRADES).reduce((acc, key) => { acc[key] = { owned: 0, cost: UPGRADES[key as keyof typeof UPGRADES].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 })); } async onMessage(message: string, sender: Party.Connection) { const data = JSON.parse(message) as Message; // 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, }); const authenticatedUserId = sessionClaims.sub; // Ensure the userId from the client matches the authenticated userId if (authenticatedUserId !== data.userId) { console.warn(`User ID mismatch: Client sent ${data.userId}, but token is for ${authenticatedUserId}`); sender.close(); // Close connection for potential tampering return; } // Update user info on the server with authenticated data if (!this.gameState.users[authenticatedUserId]) { this.gameState.users[authenticatedUserId] = { name: data.userName, // Use the name provided by the client, which comes from Clerk clicks: 0, lastSeen: Date.now() }; } this.gameState.users[authenticatedUserId].lastSeen = Date.now(); this.gameState.users[authenticatedUserId].name = data.userName; // Update name in case it changed switch (data.type) { case 'user-join': // User join is handled by the token verification and user state update above break; case 'click': this.handleClick(data, authenticatedUserId); break; case 'purchase-upgrade': this.handlePurchaseUpgrade(data, authenticatedUserId); break; } } catch (error) { console.error('Clerk token verification failed:', error); sender.close(); // Close connection if token is invalid return; } } else { // For messages without a token (e.g., initial connection before token is sent, or unauthenticated actions) // For this game, we only allow authenticated actions. console.warn('Received unauthenticated message, closing connection.'); sender.close(); return; } this.broadcast(); } // handleUserJoin is now largely integrated into onMessage's token verification // Keeping it for now, but it might become redundant or simplified. 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 clickValue = this.gameState.clickMultiplier; 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() }; } this.gameState.users[authenticatedUserId].clicks += clickValue; this.gameState.users[authenticatedUserId].lastSeen = Date.now(); this.checkMilestones(); } handlePurchaseUpgrade(data: PurchaseUpgradeMessage, authenticatedUserId: string) { const upgrade = UPGRADES[data.upgradeId as keyof typeof UPGRADES]; const currentUpgrade = this.gameState.upgrades[data.upgradeId]; if (!upgrade || !currentUpgrade) return; const userClicks = this.gameState.users[authenticatedUserId]?.clicks || 0; if (userClicks >= currentUpgrade.cost) { this.gameState.users[authenticatedUserId].clicks -= currentUpgrade.cost; currentUpgrade.owned += 1; currentUpgrade.cost = Math.floor(upgrade.baseCost * Math.pow(upgrade.multiplier, currentUpgrade.owned)); this.updateGameMultipliers(); } } updateGameMultipliers() { this.gameState.clickMultiplier = 1; this.gameState.autoClickRate = 0; Object.entries(this.gameState.upgrades).forEach(([upgradeId, upgrade]) => { const config = UPGRADES[upgradeId as keyof typeof UPGRADES]; if ('clickBonus' in config && config.clickBonus) { this.gameState.clickMultiplier += config.clickBonus * upgrade.owned; } if ('autoClickRate' in config && config.autoClickRate) { this.gameState.autoClickRate += config.autoClickRate * upgrade.owned; } }); 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;