613 lines
24 KiB
TypeScript
613 lines
24 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;
|
|
ratPoisonChallenge?: {
|
|
challengeString: string;
|
|
expiresAt: number;
|
|
};
|
|
ratPoisonImmunityUntil?: 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 }>;
|
|
}
|
|
|
|
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<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: ALL_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,
|
|
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) {
|
|
console.log(`Rat Poison: Attempt to poison self or non-existent user (${targetUserId}). Aborting.`);
|
|
return; // Cannot poison self or non-existent user
|
|
}
|
|
|
|
// Check if the target user is currently online
|
|
if (!this.userConnections.has(targetUserId)) {
|
|
const attackerConn = this.userConnections.get(authenticatedUserId);
|
|
if (attackerConn) {
|
|
attackerConn.send(JSON.stringify({
|
|
type: 'rat-poison-feedback',
|
|
message: `${targetUserState.name} is currently offline and cannot be targeted.`
|
|
}));
|
|
}
|
|
console.log(`Rat Poison: Target user ${targetUserState.name} (${targetUserId}) is offline. Attacker: ${attackerUserState.name} (${authenticatedUserId}).`);
|
|
return;
|
|
}
|
|
|
|
|
|
|
|
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!`
|
|
}));
|
|
}
|
|
console.log(`Rat Poison: Target user ${targetUserState.name} (${targetUserId}) is immune until ${new Date(targetUserState.ratPoisonImmunityUntil).toISOString()}. Attacker: ${attackerUserState.name} (${authenticatedUserId}).`);
|
|
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('Rat Poison: 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,
|
|
};
|
|
targetUserState.ratPoisonImmunityUntil = expiresAt; // User is immune for the duration of the challenge
|
|
|
|
console.log(`Rat Poison: Challenge issued to ${targetUserState.name} (${targetUserId}). String: "${challengeString}", Expires: ${new Date(expiresAt).toISOString()}.`);
|
|
|
|
// 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
|
|
console.log(`Rat Poison: Challenge for ${targetUserState.name} (${targetUserId}) expired.`);
|
|
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) {
|
|
console.log(`Rat Poison: No active challenge for user ${authenticatedUserId}. Aborting solve attempt.`);
|
|
return; // No active challenge
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now > userState.ratPoisonChallenge.expiresAt) {
|
|
// Challenge expired
|
|
console.log(`Rat Poison: User ${authenticatedUserId} attempted to solve expired challenge.`);
|
|
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.'
|
|
}));
|
|
}
|
|
console.log(`Rat Poison: User ${authenticatedUserId} successfully solved challenge. Immunity until ${new Date(userState.ratPoisonImmunityUntil).toISOString()}.`);
|
|
} else {
|
|
// Challenge failed (wrong string)
|
|
console.log(`Rat Poison: User ${authenticatedUserId} failed challenge (incorrect 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) {
|
|
console.log(`Rat Poison: Attempted to apply penalty to non-existent user ${userId}.`);
|
|
return;
|
|
}
|
|
|
|
const clicksLost = Math.floor(userState.clicks * 0.30);
|
|
userState.clicks -= clicksLost;
|
|
userState.ratPoisonImmunityUntil = Date.now() + (20 * 1000); // 20 seconds immunity
|
|
console.log(`Rat Poison: Penalty applied to ${userState.name} (${userId}). Lost ${clicksLost} clicks. Immunity until ${new Date(userState.ratPoisonImmunityUntil).toISOString()}.`);
|
|
|
|
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;
|