Files
bozoclicker/party/index.ts
2025-08-03 00:51:07 +05:30

231 lines
8.4 KiB
TypeScript

/* 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<string, { name: string; clicks: number; lastSeen: number }>;
upgrades: Record<string, { owned: number; cost: number }>;
milestones: Record<string, boolean>;
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<typeof createClerkClient>;
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<string, { owned: number; cost: number }>),
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;