A Typescript server emulator for Box Critters, a defunct virtual world.
1// deno-lint-ignore-file no-explicit-any 2import { Server } from "https://deno.land/x/socket_io@0.2.0/mod.ts"; 3import { decode } from 'hono/jwt'; 4import chalk from "https://deno.land/x/chalk_deno@v4.1.1-deno/source/index.js"; 5import { z } from "zod"; 6 7import * as world from "./constants/world.ts"; 8import * as items from "./constants/items.ts"; 9import * as utils from "./utils.ts"; 10import { LocalPlayer, PlayerCrumb, ShopData, CritterId } from "./types.ts"; 11 12import parties from "./constants/parties.json" with { type: "json" }; 13import itemsJSON from "./public/base/items.json" with { type: "json" }; 14 15export const io = new Server(); 16io.on("connection", (socket) => { 17 let localPlayer: LocalPlayer; 18 19 /** Condensed player data that is sufficient enough for other clients */ 20 let localCrumb: PlayerCrumb; 21 22 // TODO: implement checking PlayFab API with ticket 23 socket.once("login", async (ticket: string) => { 24 if (z.object({ 25 ticket: z.string() 26 }).safeParse({ ticket: ticket }).success == false) return; 27 28 let playerData; 29 try { 30 playerData = decode(ticket); 31 } catch(_e) { 32 socket.disconnect(true); 33 return 34 } 35 36 // TODO: make this just an inline function instead of having an onPropertyChange function, I'm just really lazy right now lol -index 37 function onPropertyChange(property: string, value: any) { 38 utils.updateAccount(localPlayer.nickname, property, value); 39 } 40 41 const createArrayHandler = (propertyName: string) => ({ 42 get(target: any, property: string) { 43 if (typeof target[property] === 'function') { 44 return function (...args: any[]) { 45 const result = target[property].apply(target, args); 46 onPropertyChange(propertyName, target); 47 return result; 48 }; 49 } 50 return target[property]; 51 } 52 }); 53 54 const handler = { 55 set(target: any, property: string, value: any) { 56 if (Array.isArray(value)) { 57 target[property] = new Proxy(value, createArrayHandler(property)); 58 onPropertyChange(property, target[property]); 59 } else { 60 target[property] = value; 61 onPropertyChange(property, value); 62 } 63 return true; 64 }, 65 get(target: any, property: string) { 66 if (Array.isArray(target[property])) { 67 return new Proxy(target[property], createArrayHandler(property)); 68 } 69 return target[property]; 70 } 71 }; 72 73 const payload = playerData.payload; 74 const sub = payload.sub as { 75 playerId: string, 76 nickname: string, 77 critterId: CritterId, 78 partyId: string, 79 persistent: boolean, 80 mods: Array<string> 81 }; 82 const persistentAccount = await utils.getAccount(sub.nickname); 83 if (!sub.persistent || persistentAccount.individual == null) { 84 localPlayer = { 85 playerId: sub.playerId, 86 nickname: sub.nickname, 87 critterId: sub.critterId, 88 ignore: [], 89 friends: [], 90 inventory: [], 91 gear: [], 92 eggs: [], 93 coins: 150, 94 isMember: false, 95 isGuest: false, 96 isTeam: false, 97 x: 0, 98 y: 0, 99 rotation: 0, 100 mutes: [], 101 102 _partyId: sub.partyId, // This key is replaced down the line anyway 103 _mods: [] 104 }; 105 106 if (sub.persistent) { 107 utils.createAccount(localPlayer); 108 localPlayer = new Proxy<LocalPlayer>(utils.expandAccount(localPlayer), handler); 109 }; 110 } else { 111 persistentAccount.individual.critterId = sub.critterId || "hamster"; 112 persistentAccount.individual._partyId = sub.partyId || "default"; 113 persistentAccount.individual._mods = sub.mods || []; 114 115 localPlayer = new Proxy<LocalPlayer>(utils.expandAccount(persistentAccount.individual), handler); 116 } 117 118 localPlayer._partyId = socket.handshake.query.get('partyId') || 'default'; 119 world.queue.splice(world.queue.indexOf(localPlayer.nickname), 1); 120 121 localCrumb = utils.makeCrumb(localPlayer, world.spawnRoom); 122 socket.join(world.spawnRoom); 123 124 world.players[localPlayer.playerId] = localCrumb; 125 socket.emit("login", { 126 player: localPlayer, 127 spawnRoom: world.spawnRoom, 128 }); 129 }); 130 131 socket.on("joinRoom", (roomId: string) => { 132 if (z.object({ 133 roomId: z.enum(Object.keys(world.rooms) as any) 134 }).safeParse({ roomId: roomId }).success == false) return; 135 136 const _room = (world.rooms[roomId] || { default: null }).default; 137 if (!_room) return; 138 139 socket.leave(localCrumb._roomId); 140 socket.broadcast.in(localCrumb._roomId).emit("R", localCrumb); 141 142 const modEnabled = (localPlayer._mods || []).includes('roomExits'); 143 //@ts-ignore: Index type is correct 144 const correctExit = world.roomExits[localCrumb._roomId + "->" + roomId] 145 if (modEnabled && correctExit) { 146 localPlayer.x = correctExit.x; 147 localPlayer.y = correctExit.y; 148 localPlayer.rotation = correctExit.r; 149 }; 150 151 if (!modEnabled || !correctExit) { 152 localPlayer.x = _room.startX; 153 localPlayer.y = _room.startY; 154 localPlayer.rotation = _room.startR | 180; 155 }; 156 157 localCrumb = utils.makeCrumb(localPlayer, roomId); 158 world.players[localPlayer.playerId] = localCrumb; 159 160 console.log(chalk.green('> ' + localPlayer.nickname + ' joined "' + roomId + '"!')); 161 socket.join(roomId); 162 163 let playerCrumbs = Object.values(world.players).filter((crumb) => crumb._roomId == roomId); 164 if (world.npcs[roomId]) { 165 playerCrumbs = [ 166 ...playerCrumbs, 167 ...world.npcs[roomId] 168 ]; 169 }; 170 socket.emit("joinRoom", { 171 name: _room.name, 172 roomId: roomId, 173 playerCrumbs: playerCrumbs 174 }); 175 176 socket.broadcast.in(localCrumb._roomId).emit("A", localCrumb); 177 }); 178 179 socket.on("moveTo", (x: number, y: number) => { 180 const roomData = world.rooms[localCrumb._roomId][localPlayer._partyId]; 181 if (z.object({ 182 x: z.number().min(0).max(roomData.width), 183 y: z.number().min(0).max(roomData.height) 184 }).safeParse({ x: x, y: y }).success == false) return; 185 186 const newDirection = utils.getDirection(localPlayer.x, localPlayer.y, x, y); 187 188 localPlayer.x = x; 189 localPlayer.y = y; 190 localPlayer.rotation = newDirection; 191 192 localCrumb.x = x; 193 localCrumb.y = y; 194 localCrumb.r = newDirection; 195 196 io.in(localCrumb._roomId).volatile.emit("X", { 197 i: localPlayer.playerId, 198 x: x, 199 y: y, 200 r: newDirection 201 }); 202 }); 203 204 socket.on("message", (text: string) => { 205 if (z.object({ 206 text: z.string().nonempty() 207 }).safeParse({ text: text }).success == false) return; 208 209 console.log(chalk.gray(`> ${localPlayer.nickname} sent message: "%s"`), text); 210 localCrumb.m = text; 211 212 socket.broadcast.in(localCrumb._roomId).emit("M", { 213 i: localPlayer.playerId, 214 m: text 215 }); 216 217 setTimeout(() => { 218 if (localCrumb.m != text) return; 219 localCrumb.m = ""; 220 }, 5e3); 221 }); 222 223 socket.on("emote", (emote: string) => { 224 if (z.object({ 225 emote: z.string().nonempty() // TODO: make this an enum 226 }).safeParse({ emote: emote }).success == false) return; 227 228 console.log(chalk.gray(`> ${localPlayer.nickname} sent emote: %s`), emote); 229 localCrumb.e = emote; 230 231 socket.broadcast.in(localCrumb._roomId).emit("E", { 232 i: localPlayer.playerId, 233 e: emote 234 }); 235 236 setTimeout(() => { 237 if (localCrumb.e != emote) return; 238 localCrumb.e = ""; 239 }, 5e3); 240 }); 241 242 // ? Options is specified just because sometimes it is sent, but its always an empty string 243 socket.on("code", (code: string, _options?: string) => { 244 if (z.object({ 245 command: z.enum([ 246 "pop", 247 "freeitem", 248 "tbt", 249 "darkmode", 250 "spydar", 251 "allitems" 252 ]) 253 }).safeParse({ 254 command: code 255 }).success == false) return; 256 257 console.log(chalk.gray(`> ${localPlayer.nickname} sent code: %s`), code); 258 259 const addItem = function(id: string, showGUI: boolean = false) { 260 if (!localPlayer.inventory.includes(id)) { 261 socket.emit("addItem", { itemId: id, showGUI: showGUI }); 262 localPlayer.inventory.push(id); 263 } 264 } 265 266 // Misc. Codes 267 switch(code) { 268 case 'pop': { 269 socket.emit("pop", Object.values(world.players).filter((critter) => critter.c != "huggable").length); 270 break; 271 } 272 case 'freeitem': { 273 addItem(items.shop.freeItem.itemId, true); 274 break; 275 } 276 case 'tbt': { 277 const _throwbackItem = utils.getNewCodeItem(localPlayer, items.throwback); 278 if (_throwbackItem) addItem(_throwbackItem, true); 279 break; 280 } 281 case 'darkmode': { 282 addItem("3d_black", true); 283 break; 284 } 285 case 'spydar': { 286 localPlayer.gear = [ 287 "sun_orange", 288 "super_mask_black", 289 "toque_blue", 290 "dracula_cloak", 291 "headphones_black", 292 "hoodie_black" 293 ]; 294 295 if (localCrumb._roomId == "tavern") { 296 localPlayer.x = 216; 297 localPlayer.y = 118; 298 299 localCrumb.x = 216; 300 localCrumb.y = 118; 301 302 io.in(localCrumb._roomId).volatile.emit("X", { 303 i: localPlayer.playerId, 304 x: 216, 305 y: 118 306 }); 307 } 308 309 io.in(localCrumb._roomId).emit("G", { 310 i: localPlayer.playerId, 311 g: localPlayer.gear 312 }); 313 314 socket.emit("updateGear", localPlayer.gear); 315 break; 316 } 317 case 'allitems': { 318 for (const item of itemsJSON) { 319 addItem(item.itemId, false); 320 } 321 break; 322 } 323 }; 324 325 // Item Codes 326 const _itemCodes = items.codes as Record<string, string|Array<string>> 327 const item = _itemCodes[code]; 328 329 if (typeof(item) == "string") { 330 addItem(item, true); 331 } else if (typeof(item) == "object") { 332 for (const _ of item) { 333 addItem(_, true); 334 } 335 } 336 337 // Event Codes (eg. Christmas 2019) 338 const _eventItemCodes = items.eventCodes as Record<string, Record<string, string>>; 339 const eventItem = (_eventItemCodes[localPlayer._partyId] || {})[code]; 340 if (eventItem) addItem(eventItem); 341 }); 342 343 socket.on("updateGear", (gear: Array<string>) => { 344 if (z.object({ 345 gear: z.array(z.string().nonempty()).default([]) 346 }).strict().safeParse({ gear: gear }).success == false) return; 347 348 const _gear = []; 349 for (const itemId of gear) { 350 if (localPlayer.inventory.includes(itemId)) { 351 _gear.push(itemId) 352 } 353 } 354 localPlayer.gear = _gear; 355 356 io.in(localCrumb._roomId).emit("G", { 357 i: localPlayer.playerId, 358 g: localPlayer.gear 359 }); 360 361 socket.emit("updateGear", localPlayer.gear); 362 }); 363 364 socket.on("getShop", () => { 365 const _shopItems = items.shop as unknown as ShopData; 366 socket.emit("getShop", { 367 lastItem: _shopItems.lastItem.itemId, 368 freeItem: _shopItems.freeItem.itemId, 369 nextItem: _shopItems.nextItem.itemId, 370 collection: _shopItems.collection.map((item) => item.itemId) 371 }) 372 }); 373 374 socket.on("buyItem", (itemId: string) => { 375 if (z.object({ 376 itemId: z.string().nonempty() 377 }).strict().safeParse({ itemId: itemId }).success == false) return; 378 379 // ? Free item is excluded from this list because the game just sends the "/freeitem" code 380 const currentShop = items.shop; 381 const _shopItems = [currentShop.lastItem, currentShop.nextItem, ...currentShop.collection] 382 383 const target = _shopItems.find((item) => item.itemId == itemId)!; 384 if (!target) { 385 console.log(chalk.red("> There is no item in this week's shop with itemId: %s"), itemId); 386 return; 387 }; 388 389 if (localPlayer.coins >= target.cost && !localPlayer.inventory.includes(itemId)) { 390 console.log(chalk.green("[+] Bought item: %s for %d coins"), itemId, target.cost); 391 localPlayer.coins -= target.cost; 392 localPlayer.inventory.push(itemId); 393 394 socket.emit("buyItem", { itemId: itemId }); 395 socket.emit("updateCoins", { balance: localPlayer.coins }); 396 } 397 }); 398 399 socket.on("trigger", async () => { 400 const activatedTrigger = await utils.getTrigger(localPlayer, localCrumb._roomId, localPlayer._partyId); 401 if (!activatedTrigger) return; 402 403 if (activatedTrigger.hasItems) { 404 for (const item of activatedTrigger.hasItems) { 405 if (!localPlayer.inventory.includes(item)) return; 406 } 407 } 408 409 if (activatedTrigger.grantItem) { 410 let items = activatedTrigger.grantItem; 411 if (typeof(items) == 'string') items = [items]; 412 413 for (const item of items) { 414 if (!localPlayer.inventory.includes(item)) { 415 socket.emit("addItem", { itemId: item, showGUI: true }); 416 localPlayer.inventory.push(item); 417 } 418 } 419 } 420 421 if (activatedTrigger.addEgg) { 422 const egg = activatedTrigger.addEgg; 423 socket.emit("addEgg", egg); 424 localPlayer.eggs.push(egg); 425 } 426 }); 427 428 socket.on("addIgnore", (playerId: string) => { 429 if (z.object({ 430 playerId: z.enum(Object.keys(world.players) as any) 431 }).strict().safeParse({ playerId: playerId }).success == false) return; 432 433 if (Object.keys(world.players).includes(playerId) && !localPlayer.ignore.includes(playerId)) { 434 localPlayer.ignore.push(playerId); 435 }; 436 }); 437 438 socket.on("attack", (playerId: string) => { 439 if (z.object({ 440 playerId: z.enum(Object.keys(world.players) as any) 441 }).strict().safeParse({ playerId: playerId }).success == false) return; 442 443 if (!localPlayer.gear.includes('bb_beebee')) return; 444 const monster = Object.values(world.players).find((player) => player.i == playerId && player.c == "huggable"); 445 446 if (monster) { 447 io.in(localCrumb._roomId).emit("R", monster); 448 449 localPlayer.coins += 10; 450 socket.emit("updateCoins", { balance: localPlayer.coins }); 451 452 delete world.players[playerId]; 453 }; 454 }); 455 456 socket.on("switchParty", (partyId: string) => { 457 if (z.object({ 458 partyId: z.enum(parties as any) 459 }).strict().safeParse({ partyId: partyId }).success == false) return; 460 461 localPlayer._partyId = partyId; 462 socket.emit("switchParty"); 463 }); 464 465 socket.on("beep", () => socket.emit("beep")); 466 467 socket.on("disconnect", (reason) => { 468 if (reason == "server namespace disconnect") return; 469 470 if (localPlayer && localCrumb) { 471 io.in(localCrumb._roomId).emit("R", localCrumb); 472 delete world.players[localPlayer.playerId]; 473 }; 474 }); 475});