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 { z } from "zod"; 4 5import * as world from "../constants/world.ts"; 6import * as items from "../constants/items.ts"; 7import * as utils from "../src/utils.ts"; 8import { CritterId, LocalPlayer, PlayerCrumb, ShopData } from "../src/types.ts"; 9 10import parties from "../constants/parties.json" with { type: "json" }; 11import itemsJSON from "../public/base/items.json" with { type: "json" }; 12 13export const io = new Server(); 14io.on("connection", (socket) => { 15 let localPlayer: LocalPlayer; 16 17 /** Condensed player data that is sufficient enough for other clients */ 18 let localCrumb: PlayerCrumb; 19 20 // TODO: implement checking PlayFab API with ticket 21 socket.once("login", async (ticket: string) => { 22 if ( 23 z.object({ 24 ticket: z.string(), 25 }).safeParse({ ticket: ticket }).success == false 26 ) return; 27 28 let playerData; 29 try { 30 playerData = await utils.verifyJWT(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 //@ts-ignore: I will fix the type errors with using a different JWT library eventually 74 const sub = playerData as { 75 playerId: string; 76 nickname: string; 77 critterId: CritterId; 78 partyId: string; 79 persistent: boolean; 80 mods: Array<string>; 81 }; 82 83 if ([ 84 "today2019", 85 "today2020", 86 "today2021" 87 ].includes(sub.partyId)) { 88 console.log('target year:', parseInt(sub.partyId.replace('today', ''))); 89 sub.partyId = utils.getCurrentEvent(parseInt(sub.partyId.replace('today', ''))) 90 }; 91 92 const persistentAccount = await utils.getAccount(sub.nickname); 93 if (!sub.persistent || persistentAccount.individual == null) { 94 localPlayer = { 95 playerId: sub.playerId, 96 nickname: sub.nickname, 97 critterId: sub.critterId, 98 ignore: [], 99 friends: [], 100 inventory: [], 101 gear: [], 102 eggs: [], 103 coins: 150, 104 isMember: false, 105 isGuest: false, 106 isTeam: false, 107 x: 0, 108 y: 0, 109 rotation: 0, 110 mutes: [], 111 112 _partyId: sub.partyId, // This key is replaced down the line anyway 113 _mods: [], 114 }; 115 116 if (sub.persistent) { 117 utils.createAccount(localPlayer); 118 localPlayer = new Proxy<LocalPlayer>( 119 utils.expandAccount(localPlayer), 120 handler, 121 ); 122 } 123 } else { 124 persistentAccount.individual.critterId = sub.critterId || "hamster"; 125 persistentAccount.individual._partyId = sub.partyId || "default"; 126 persistentAccount.individual._mods = sub.mods || []; 127 128 localPlayer = new Proxy<LocalPlayer>( 129 utils.expandAccount(persistentAccount.individual), 130 handler, 131 ); 132 } 133 134 localPlayer._partyId = socket.handshake.query.get("partyId") || "default"; 135 world.queue.splice(world.queue.indexOf(localPlayer.nickname), 1); 136 137 localCrumb = utils.makeCrumb(localPlayer, world.spawnRoom); 138 socket.join(world.spawnRoom); 139 140 world.players[localPlayer.playerId] = localCrumb; 141 socket.emit("login", { 142 player: localPlayer, 143 spawnRoom: world.spawnRoom, 144 }); 145 }); 146 147 socket.on("joinRoom", (roomId: string) => { 148 if ( 149 z.object({ 150 roomId: z.enum(Object.keys(world.rooms) as any), 151 }).safeParse({ roomId: roomId }).success == false 152 ) return; 153 154 const _room = (world.rooms[roomId] || { default: null }).default; 155 if (!_room) return; 156 157 socket.leave(localCrumb._roomId); 158 socket.broadcast.in(localCrumb._roomId).emit("R", localCrumb); 159 160 const modEnabled = (localPlayer._mods || []).includes("roomExits"); 161 //@ts-ignore: Index type is correct 162 const correctExit = world.roomExits[localCrumb._roomId + "->" + roomId]; 163 if (modEnabled && correctExit) { 164 localPlayer.x = correctExit.x; 165 localPlayer.y = correctExit.y; 166 localPlayer.rotation = correctExit.r; 167 } 168 169 if (!modEnabled || !correctExit) { 170 localPlayer.x = _room.startX; 171 localPlayer.y = _room.startY; 172 localPlayer.rotation = _room.startR | 180; 173 } 174 175 localCrumb = utils.makeCrumb(localPlayer, roomId); 176 world.players[localPlayer.playerId] = localCrumb; 177 178 console.log("> " + localPlayer.nickname + ' joined "' + roomId + '"!'); 179 socket.join(roomId); 180 181 let playerCrumbs = Object.values(world.players).filter((crumb) => 182 crumb._roomId == roomId 183 ); 184 if (world.npcs[roomId]) { 185 playerCrumbs = [ 186 ...playerCrumbs, 187 ...world.npcs[roomId], 188 ]; 189 } 190 socket.emit("joinRoom", { 191 name: _room.name, 192 roomId: roomId, 193 playerCrumbs: playerCrumbs, 194 }); 195 196 socket.broadcast.in(localCrumb._roomId).emit("A", localCrumb); 197 }); 198 199 socket.on("moveTo", (x: number, y: number) => { 200 const roomData = world.rooms[localCrumb._roomId][localPlayer._partyId] || 201 world.rooms[localCrumb._roomId].default; 202 if ( 203 z.object({ 204 x: z.number().min(0).max(roomData.width), 205 y: z.number().min(0).max(roomData.height), 206 }).safeParse({ x: x, y: y }).success == false 207 ) return; 208 209 const newDirection = utils.getDirection(localPlayer.x, localPlayer.y, x, y); 210 211 localPlayer.x = x; 212 localPlayer.y = y; 213 localPlayer.rotation = newDirection; 214 215 localCrumb.x = x; 216 localCrumb.y = y; 217 localCrumb.r = newDirection; 218 219 io.in(localCrumb._roomId).volatile.emit("X", { 220 i: localPlayer.playerId, 221 x: x, 222 y: y, 223 r: newDirection, 224 }); 225 }); 226 227 socket.on("message", (text: string) => { 228 if ( 229 z.object({ 230 text: z.string().nonempty(), 231 }).safeParse({ text: text }).success == false 232 ) return; 233 234 console.log(`> ${localPlayer.nickname} sent message:`, text); 235 localCrumb.m = text; 236 237 socket.broadcast.in(localCrumb._roomId).emit("M", { 238 i: localPlayer.playerId, 239 m: text, 240 }); 241 242 setTimeout(() => { 243 if (localCrumb.m != text) return; 244 localCrumb.m = ""; 245 }, 5e3); 246 }); 247 248 socket.on("emote", (emote: string) => { 249 if ( 250 z.object({ 251 emote: z.string().nonempty(), // TODO: make this an enum 252 }).safeParse({ emote: emote }).success == false 253 ) return; 254 255 console.log(`> ${localPlayer.nickname} sent emote:`, emote); 256 localCrumb.e = emote; 257 258 socket.broadcast.in(localCrumb._roomId).emit("E", { 259 i: localPlayer.playerId, 260 e: emote, 261 }); 262 263 setTimeout(() => { 264 if (localCrumb.e != emote) return; 265 localCrumb.e = ""; 266 }, 5e3); 267 }); 268 269 // ? Options is specified just because sometimes it is sent, but its always an empty string 270 socket.on("code", (code: string, _options?: string) => { 271 if ( 272 z.object({ 273 command: z.enum([ 274 "pop", 275 "freeitem", 276 "tbt", 277 "darkmode", 278 "spydar", 279 "allitems", 280 ]), 281 }).safeParse({ 282 command: code, 283 }).success == false 284 ) return; 285 286 console.log(`> ${localPlayer.nickname} sent code:`, code); 287 288 const addItem = function (id: string, showGUI: boolean = false) { 289 if (!localPlayer.inventory.includes(id)) { 290 socket.emit("addItem", { itemId: id, showGUI: showGUI }); 291 localPlayer.inventory.push(id); 292 } 293 }; 294 295 // Misc. Codes 296 switch (code) { 297 case "pop": { 298 socket.emit( 299 "pop", 300 Object.values(world.players).filter((critter) => 301 critter.c != "huggable" 302 ).length, 303 ); 304 break; 305 } 306 case "freeitem": { 307 addItem(items.shop.freeItem.itemId, true); 308 break; 309 } 310 case "tbt": { 311 const _throwbackItem = utils.getNewCodeItem( 312 localPlayer, 313 items.throwback, 314 ); 315 if (_throwbackItem) addItem(_throwbackItem, true); 316 break; 317 } 318 case "darkmode": { 319 addItem("3d_black", true); 320 break; 321 } 322 case "spydar": { 323 localPlayer.gear = [ 324 "sun_orange", 325 "super_mask_black", 326 "toque_blue", 327 "dracula_cloak", 328 "headphones_black", 329 "hoodie_black", 330 ]; 331 332 if (localCrumb._roomId == "tavern") { 333 localPlayer.x = 216; 334 localPlayer.y = 118; 335 336 localCrumb.x = 216; 337 localCrumb.y = 118; 338 339 io.in(localCrumb._roomId).volatile.emit("X", { 340 i: localPlayer.playerId, 341 x: 216, 342 y: 118, 343 }); 344 } 345 346 io.in(localCrumb._roomId).emit("G", { 347 i: localPlayer.playerId, 348 g: localPlayer.gear, 349 }); 350 351 socket.emit("updateGear", localPlayer.gear); 352 break; 353 } 354 case "allitems": { 355 for (const item of itemsJSON) { 356 addItem(item.itemId, false); 357 } 358 break; 359 } 360 } 361 362 // Item Codes 363 const _itemCodes = items.codes as Record<string, string | Array<string>>; 364 const item = _itemCodes[code]; 365 366 if (typeof item == "string") { 367 addItem(item, true); 368 } else if (typeof item == "object") { 369 for (const _ of item) { 370 addItem(_, true); 371 } 372 } 373 374 // Event Codes (eg. Christmas 2019) 375 const _eventItemCodes = items.eventCodes as Record< 376 string, 377 Record<string, string> 378 >; 379 const eventItem = (_eventItemCodes[localPlayer._partyId] || {})[code]; 380 if (eventItem) addItem(eventItem); 381 }); 382 383 socket.on("updateGear", (gear: Array<string>) => { 384 if ( 385 z.object({ 386 gear: z.array(z.string().nonempty()).default([]), 387 }).strict().safeParse({ gear: gear }).success == false 388 ) return; 389 390 const _gear = []; 391 for (const itemId of gear) { 392 if (localPlayer.inventory.includes(itemId)) { 393 _gear.push(itemId); 394 } 395 } 396 localPlayer.gear = _gear; 397 398 io.in(localCrumb._roomId).emit("G", { 399 i: localPlayer.playerId, 400 g: localPlayer.gear, 401 }); 402 403 socket.emit("updateGear", localPlayer.gear); 404 }); 405 406 socket.on("getShop", () => { 407 const _shopItems = items.shop as unknown as ShopData; 408 socket.emit("getShop", { 409 lastItem: _shopItems.lastItem.itemId, 410 freeItem: _shopItems.freeItem.itemId, 411 nextItem: _shopItems.nextItem.itemId, 412 collection: _shopItems.collection.map((item) => item.itemId), 413 }); 414 }); 415 416 socket.on("buyItem", (itemId: string) => { 417 if ( 418 z.object({ 419 itemId: z.string().nonempty(), 420 }).strict().safeParse({ itemId: itemId }).success == false 421 ) return; 422 423 // ? Free item is excluded from this list because the game just sends the "/freeitem" code 424 const currentShop = items.shop; 425 const _shopItems = [ 426 currentShop.lastItem, 427 currentShop.nextItem, 428 ...currentShop.collection, 429 ]; 430 431 const target = _shopItems.find((item) => item.itemId == itemId)!; 432 if (!target) { 433 console.log( 434 "> There is no item in this week's shop with itemId:", 435 itemId, 436 ); 437 return; 438 } 439 440 if ( 441 localPlayer.coins >= target.cost && 442 !localPlayer.inventory.includes(itemId) 443 ) { 444 console.log( 445 "[+] Bought item: " + itemId + " for " + target.cost + " coins", 446 ); 447 localPlayer.coins -= target.cost; 448 localPlayer.inventory.push(itemId); 449 450 socket.emit("buyItem", { itemId: itemId }); 451 socket.emit("updateCoins", { balance: localPlayer.coins }); 452 } 453 }); 454 455 socket.on("trigger", async () => { 456 const activatedTrigger = await utils.getTrigger( 457 localPlayer, 458 localCrumb._roomId, 459 localPlayer._partyId, 460 ); 461 if (!activatedTrigger) return; 462 463 if (activatedTrigger.hasItems) { 464 for (const item of activatedTrigger.hasItems) { 465 if (!localPlayer.inventory.includes(item)) return; 466 } 467 } 468 469 if (activatedTrigger.grantItem) { 470 let items = activatedTrigger.grantItem; 471 if (typeof items == "string") items = [items]; 472 473 for (const item of items) { 474 if (!localPlayer.inventory.includes(item)) { 475 socket.emit("addItem", { itemId: item, showGUI: true }); 476 localPlayer.inventory.push(item); 477 } 478 } 479 } 480 481 if (activatedTrigger.addEgg) { 482 const egg = activatedTrigger.addEgg; 483 socket.emit("addEgg", egg); 484 localPlayer.eggs.push(egg); 485 } 486 }); 487 488 socket.on("addIgnore", (playerId: string) => { 489 if ( 490 z.object({ 491 playerId: z.enum(Object.keys(world.players) as any), 492 }).strict().safeParse({ playerId: playerId }).success == false 493 ) return; 494 495 if ( 496 Object.keys(world.players).includes(playerId) && 497 !localPlayer.ignore.includes(playerId) 498 ) { 499 localPlayer.ignore.push(playerId); 500 } 501 }); 502 503 socket.on("attack", (playerId: string) => { 504 if ( 505 z.object({ 506 playerId: z.enum(Object.keys(world.players) as any), 507 }).strict().safeParse({ playerId: playerId }).success == false 508 ) return; 509 510 if (!localPlayer.gear.includes("bb_beebee")) return; 511 const monster = Object.values(world.players).find((player) => 512 player.i == playerId && player.c == "huggable" 513 ); 514 515 if (monster) { 516 io.in(localCrumb._roomId).emit("R", monster); 517 518 localPlayer.coins += 10; 519 socket.emit("updateCoins", { balance: localPlayer.coins }); 520 521 delete world.players[playerId]; 522 } 523 }); 524 525 socket.on("switchParty", (partyId: string) => { 526 if ( 527 z.object({ 528 partyId: z.enum(Object.keys(parties) as any), 529 }).strict().safeParse({ partyId: partyId }).success == false 530 ) return; 531 532 localPlayer._partyId = partyId; 533 socket.emit("switchParty"); 534 }); 535 536 socket.on("beep", () => socket.emit("beep")); 537 538 socket.on("disconnect", (reason) => { 539 if (reason == "server namespace disconnect") return; 540 541 if (localPlayer && localCrumb) { 542 io.in(localCrumb._roomId).emit("R", localCrumb); 543 delete world.players[localPlayer.playerId]; 544 } 545 }); 546});