// deno-lint-ignore-file no-explicit-any import { Server } from "https://deno.land/x/socket_io@0.2.0/mod.ts"; import { decode } from 'hono/jwt'; import chalk from "https://deno.land/x/chalk_deno@v4.1.1-deno/source/index.js"; import { z } from "zod"; import * as world from "./constants/world.ts"; import * as items from "./constants/items.ts"; import * as utils from "./utils.ts"; import { LocalPlayer, PlayerCrumb, ShopData, CritterId } from "./types.ts"; import parties from "./constants/parties.json" with { type: "json" }; import itemsJSON from "./public/base/items.json" with { type: "json" }; export const io = new Server(); io.on("connection", (socket) => { let localPlayer: LocalPlayer; /** Condensed player data that is sufficient enough for other clients */ let localCrumb: PlayerCrumb; // TODO: implement checking PlayFab API with ticket socket.once("login", async (ticket: string) => { if (z.object({ ticket: z.string() }).safeParse({ ticket: ticket }).success == false) return; let playerData; try { playerData = decode(ticket); } catch(_e) { socket.disconnect(true); return } // TODO: make this just an inline function instead of having an onPropertyChange function, I'm just really lazy right now lol -index function onPropertyChange(property: string, value: any) { utils.updateAccount(localPlayer.nickname, property, value); } const createArrayHandler = (propertyName: string) => ({ get(target: any, property: string) { if (typeof target[property] === 'function') { return function (...args: any[]) { const result = target[property].apply(target, args); onPropertyChange(propertyName, target); return result; }; } return target[property]; } }); const handler = { set(target: any, property: string, value: any) { if (Array.isArray(value)) { target[property] = new Proxy(value, createArrayHandler(property)); onPropertyChange(property, target[property]); } else { target[property] = value; onPropertyChange(property, value); } return true; }, get(target: any, property: string) { if (Array.isArray(target[property])) { return new Proxy(target[property], createArrayHandler(property)); } return target[property]; } }; const payload = playerData.payload; const sub = payload.sub as { playerId: string, nickname: string, critterId: CritterId, partyId: string, persistent: boolean, mods: Array }; const persistentAccount = await utils.getAccount(sub.nickname); if (!sub.persistent || persistentAccount.individual == null) { localPlayer = { playerId: sub.playerId, nickname: sub.nickname, critterId: sub.critterId, ignore: [], friends: [], inventory: [], gear: [], eggs: [], coins: 150, isMember: false, isGuest: false, isTeam: false, x: 0, y: 0, rotation: 0, mutes: [], _partyId: sub.partyId, // This key is replaced down the line anyway _mods: [] }; if (sub.persistent) { utils.createAccount(localPlayer); localPlayer = new Proxy(utils.expandAccount(localPlayer), handler); }; } else { persistentAccount.individual.critterId = sub.critterId || "hamster"; persistentAccount.individual._partyId = sub.partyId || "default"; persistentAccount.individual._mods = sub.mods || []; localPlayer = new Proxy(utils.expandAccount(persistentAccount.individual), handler); } localPlayer._partyId = socket.handshake.query.get('partyId') || 'default'; world.queue.splice(world.queue.indexOf(localPlayer.nickname), 1); localCrumb = utils.makeCrumb(localPlayer, world.spawnRoom); socket.join(world.spawnRoom); world.players[localPlayer.playerId] = localCrumb; socket.emit("login", { player: localPlayer, spawnRoom: world.spawnRoom, }); }); socket.on("joinRoom", (roomId: string) => { if (z.object({ roomId: z.enum(Object.keys(world.rooms) as any) }).safeParse({ roomId: roomId }).success == false) return; const _room = (world.rooms[roomId] || { default: null }).default; if (!_room) return; socket.leave(localCrumb._roomId); socket.broadcast.in(localCrumb._roomId).emit("R", localCrumb); const modEnabled = (localPlayer._mods || []).includes('roomExits'); //@ts-ignore: Index type is correct const correctExit = world.roomExits[localCrumb._roomId + "->" + roomId] if (modEnabled && correctExit) { localPlayer.x = correctExit.x; localPlayer.y = correctExit.y; localPlayer.rotation = correctExit.r; }; if (!modEnabled || !correctExit) { localPlayer.x = _room.startX; localPlayer.y = _room.startY; localPlayer.rotation = _room.startR | 180; }; localCrumb = utils.makeCrumb(localPlayer, roomId); world.players[localPlayer.playerId] = localCrumb; console.log(chalk.green('> ' + localPlayer.nickname + ' joined "' + roomId + '"!')); socket.join(roomId); let playerCrumbs = Object.values(world.players).filter((crumb) => crumb._roomId == roomId); if (world.npcs[roomId]) { playerCrumbs = [ ...playerCrumbs, ...world.npcs[roomId] ]; }; socket.emit("joinRoom", { name: _room.name, roomId: roomId, playerCrumbs: playerCrumbs }); socket.broadcast.in(localCrumb._roomId).emit("A", localCrumb); }); socket.on("moveTo", (x: number, y: number) => { const roomData = world.rooms[localCrumb._roomId][localPlayer._partyId]; if (z.object({ x: z.number().min(0).max(roomData.width), y: z.number().min(0).max(roomData.height) }).safeParse({ x: x, y: y }).success == false) return; const newDirection = utils.getDirection(localPlayer.x, localPlayer.y, x, y); localPlayer.x = x; localPlayer.y = y; localPlayer.rotation = newDirection; localCrumb.x = x; localCrumb.y = y; localCrumb.r = newDirection; io.in(localCrumb._roomId).volatile.emit("X", { i: localPlayer.playerId, x: x, y: y, r: newDirection }); }); socket.on("message", (text: string) => { if (z.object({ text: z.string().nonempty() }).safeParse({ text: text }).success == false) return; console.log(chalk.gray(`> ${localPlayer.nickname} sent message: "%s"`), text); localCrumb.m = text; socket.broadcast.in(localCrumb._roomId).emit("M", { i: localPlayer.playerId, m: text }); setTimeout(() => { if (localCrumb.m != text) return; localCrumb.m = ""; }, 5e3); }); socket.on("emote", (emote: string) => { if (z.object({ emote: z.string().nonempty() // TODO: make this an enum }).safeParse({ emote: emote }).success == false) return; console.log(chalk.gray(`> ${localPlayer.nickname} sent emote: %s`), emote); localCrumb.e = emote; socket.broadcast.in(localCrumb._roomId).emit("E", { i: localPlayer.playerId, e: emote }); setTimeout(() => { if (localCrumb.e != emote) return; localCrumb.e = ""; }, 5e3); }); // ? Options is specified just because sometimes it is sent, but its always an empty string socket.on("code", (code: string, _options?: string) => { if (z.object({ command: z.enum([ "pop", "freeitem", "tbt", "darkmode", "spydar", "allitems" ]) }).safeParse({ command: code }).success == false) return; console.log(chalk.gray(`> ${localPlayer.nickname} sent code: %s`), code); const addItem = function(id: string, showGUI: boolean = false) { if (!localPlayer.inventory.includes(id)) { socket.emit("addItem", { itemId: id, showGUI: showGUI }); localPlayer.inventory.push(id); } } // Misc. Codes switch(code) { case 'pop': { socket.emit("pop", Object.values(world.players).filter((critter) => critter.c != "huggable").length); break; } case 'freeitem': { addItem(items.shop.freeItem.itemId, true); break; } case 'tbt': { const _throwbackItem = utils.getNewCodeItem(localPlayer, items.throwback); if (_throwbackItem) addItem(_throwbackItem, true); break; } case 'darkmode': { addItem("3d_black", true); break; } case 'spydar': { localPlayer.gear = [ "sun_orange", "super_mask_black", "toque_blue", "dracula_cloak", "headphones_black", "hoodie_black" ]; if (localCrumb._roomId == "tavern") { localPlayer.x = 216; localPlayer.y = 118; localCrumb.x = 216; localCrumb.y = 118; io.in(localCrumb._roomId).volatile.emit("X", { i: localPlayer.playerId, x: 216, y: 118 }); } io.in(localCrumb._roomId).emit("G", { i: localPlayer.playerId, g: localPlayer.gear }); socket.emit("updateGear", localPlayer.gear); break; } case 'allitems': { for (const item of itemsJSON) { addItem(item.itemId, false); } break; } }; // Item Codes const _itemCodes = items.codes as Record> const item = _itemCodes[code]; if (typeof(item) == "string") { addItem(item, true); } else if (typeof(item) == "object") { for (const _ of item) { addItem(_, true); } } // Event Codes (eg. Christmas 2019) const _eventItemCodes = items.eventCodes as Record>; const eventItem = (_eventItemCodes[localPlayer._partyId] || {})[code]; if (eventItem) addItem(eventItem); }); socket.on("updateGear", (gear: Array) => { if (z.object({ gear: z.array(z.string().nonempty()).default([]) }).strict().safeParse({ gear: gear }).success == false) return; const _gear = []; for (const itemId of gear) { if (localPlayer.inventory.includes(itemId)) { _gear.push(itemId) } } localPlayer.gear = _gear; io.in(localCrumb._roomId).emit("G", { i: localPlayer.playerId, g: localPlayer.gear }); socket.emit("updateGear", localPlayer.gear); }); socket.on("getShop", () => { const _shopItems = items.shop as unknown as ShopData; socket.emit("getShop", { lastItem: _shopItems.lastItem.itemId, freeItem: _shopItems.freeItem.itemId, nextItem: _shopItems.nextItem.itemId, collection: _shopItems.collection.map((item) => item.itemId) }) }); socket.on("buyItem", (itemId: string) => { if (z.object({ itemId: z.string().nonempty() }).strict().safeParse({ itemId: itemId }).success == false) return; // ? Free item is excluded from this list because the game just sends the "/freeitem" code const currentShop = items.shop; const _shopItems = [currentShop.lastItem, currentShop.nextItem, ...currentShop.collection] const target = _shopItems.find((item) => item.itemId == itemId)!; if (!target) { console.log(chalk.red("> There is no item in this week's shop with itemId: %s"), itemId); return; }; if (localPlayer.coins >= target.cost && !localPlayer.inventory.includes(itemId)) { console.log(chalk.green("[+] Bought item: %s for %d coins"), itemId, target.cost); localPlayer.coins -= target.cost; localPlayer.inventory.push(itemId); socket.emit("buyItem", { itemId: itemId }); socket.emit("updateCoins", { balance: localPlayer.coins }); } }); socket.on("trigger", async () => { const activatedTrigger = await utils.getTrigger(localPlayer, localCrumb._roomId, localPlayer._partyId); if (!activatedTrigger) return; if (activatedTrigger.hasItems) { for (const item of activatedTrigger.hasItems) { if (!localPlayer.inventory.includes(item)) return; } } if (activatedTrigger.grantItem) { let items = activatedTrigger.grantItem; if (typeof(items) == 'string') items = [items]; for (const item of items) { if (!localPlayer.inventory.includes(item)) { socket.emit("addItem", { itemId: item, showGUI: true }); localPlayer.inventory.push(item); } } } if (activatedTrigger.addEgg) { const egg = activatedTrigger.addEgg; socket.emit("addEgg", egg); localPlayer.eggs.push(egg); } }); socket.on("addIgnore", (playerId: string) => { if (z.object({ playerId: z.enum(Object.keys(world.players) as any) }).strict().safeParse({ playerId: playerId }).success == false) return; if (Object.keys(world.players).includes(playerId) && !localPlayer.ignore.includes(playerId)) { localPlayer.ignore.push(playerId); }; }); socket.on("attack", (playerId: string) => { if (z.object({ playerId: z.enum(Object.keys(world.players) as any) }).strict().safeParse({ playerId: playerId }).success == false) return; if (!localPlayer.gear.includes('bb_beebee')) return; const monster = Object.values(world.players).find((player) => player.i == playerId && player.c == "huggable"); if (monster) { io.in(localCrumb._roomId).emit("R", monster); localPlayer.coins += 10; socket.emit("updateCoins", { balance: localPlayer.coins }); delete world.players[playerId]; }; }); socket.on("switchParty", (partyId: string) => { if (z.object({ partyId: z.enum(parties as any) }).strict().safeParse({ partyId: partyId }).success == false) return; localPlayer._partyId = partyId; socket.emit("switchParty"); }); socket.on("beep", () => socket.emit("beep")); socket.on("disconnect", (reason) => { if (reason == "server namespace disconnect") return; if (localPlayer && localCrumb) { io.in(localCrumb._roomId).emit("R", localCrumb); delete world.players[localPlayer.playerId]; }; }); });