475 lines
No EOL
14 KiB
TypeScript
475 lines
No EOL
14 KiB
TypeScript
// 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<string>
|
|
};
|
|
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<LocalPlayer>(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<LocalPlayer>(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<string, string|Array<string>>
|
|
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<string, Record<string, string>>;
|
|
const eventItem = (_eventItemCodes[localPlayer._partyId] || {})[code];
|
|
if (eventItem) addItem(eventItem);
|
|
});
|
|
|
|
socket.on("updateGear", (gear: Array<string>) => {
|
|
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];
|
|
};
|
|
});
|
|
}); |