Files
bozoclicker/party/index.ts
Arjun S 79c38efc94 fix
2025-08-04 08:56:13 +00:00

567 lines
20 KiB
TypeScript

/* 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<string, { owned: number; cost: number }>;
clickMultiplier: number;
autoClickRate: number;
}
interface GameState {
totalClicks: number;
users: Record<string, UserState>;
milestones: Record<string, boolean>;
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<string, { owned: number; cost: number }>;
}
type Message = ClickMessage | PurchaseUpgradeMessage | ApplyMultiplierBonusMessage | UserJoinMessage | AdminBroadcastMessage | EditUserMessage; // 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: '/assets/bozo.png',
multiplier: 1.002,
rarity: 1.0,
},
{
level: 1,
imageSrc: '/assets/shoominion.png',
multiplier: 1.003,
rarity: 0.8,
},
{
level: 5,
imageSrc: '/assets/codebug.gif',
multiplier: 1.005,
rarity: 0.6,
},
{
level: 10,
imageSrc: '/assets/lalan.gif',
multiplier: 1.007,
rarity: 0.4,
},
{
level: 15,
imageSrc: '/assets/neuro-neurosama.gif',
multiplier: 1.010,
rarity: 0.2,
},
{
level: 20,
imageSrc: '/assets/evil-neurosama.gif',
multiplier: 1.015,
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: [
`Its Bo's birthday!`,
`Haru Urara looses another race, forced to eat pickle filled burger.`,
`Cupid crashes out once again, completely expected.`,
`Bo has been spotted in the wild, please do not approach.`,
`Bozo Clicker is now the number game in the world!`,
`Bo states that he did win the hidden gem vtuber award, it's just that he just didn't feel like it so Reya was really kind to step up.`,
`Reya has been spotted in the wild, please do not approach.`,
`What? Stop reading? I wrote this when you were playing roblox with cupid`,
`It's 1pm in the night and my mom is calling me to sleep, I don't want to sleep I'm making this stupid game`,
`FU BO`,
`When are you watching paint dry again?`,
`Insert Girls Kissing`,
`We luub bo`,
`MOOD DOWN`,
`All this clicking in this game wont give you your money back from those horse races`,
`UMAMUSUME PRETTY DERBY UPDATE : NEW SSSR IS ANNOUNCED - ITS [REDACTED]`,
`LEts' get maried bo`,
`My AI autocomplete on my code editor is shipping you with Reya for some reason. It literally completed Reya in this sentence`,
`DUDE`,
`There's a pipebomb in your DMs`,
`om`,
`You are the Glue to my life like how Tokai Teio is glue(stick) to a child's art project`,
]
},
{
id: 'secretVideo',
name: '🎬 Secret Video',
description: 'Unlock a secret video. Plays automatically!',
baseCost: 1000000, // Very expensive
multiplier: 1,
icon: '🎬',
oneTime: true,
youtubeId: 'ONzntmMFXGE' // The YouTube video ID
}
];
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<typeof createClerkClient>;
private userConnections: Map<string, Party.Connection> = 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: UPGRADES.reduce((acc, upgrade) => {
acc[upgrade.id] = { owned: 0, cost: upgrade.baseCost };
return acc;
}, {} as Record<string, { owned: number; cost: number }>),
clickMultiplier: 1,
autoClickRate: 0,
};
}
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;
}
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);
}
}
// 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.
// }
// 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
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;