- feat: alpha hamster critter variant
- style: format client JS
- feat: improved login validation
- style: format & update footers
- fix: mods array not being populated by JWT contents
- style: restructure socket.io server into multiple files
- style: remove unused socket.io types
This commit is contained in:
Index 2025-05-03 19:00:02 -05:00
parent 9b12e23dbf
commit 1b98772fe6
17 changed files with 7509 additions and 625 deletions

View file

@ -1,15 +0,0 @@
FROM denoland/deno:2.1.5
WORKDIR /app
COPY deno.json .
RUN deno install
COPY . .
RUN deno cache main.ts
ARG PORT=3257
EXPOSE $PORT
CMD ["task", "start"]

View file

@ -1,9 +1,11 @@
{
"imports": {
"@/": "./src/",
"@std/fs": "jsr:@std/fs@^1.0.14"
},
"tasks": {
"start": "deno run --watch --allow-net --allow-read --allow-env --env-file=.env --allow-write src/main.ts",
"start": "deno run --allow-net --allow-read --allow-env --env-file=.env --allow-write src/main.ts",
"dev": "deno run --watch --allow-net --allow-read --allow-env --env-file=.env --allow-write src/main.ts",
"build": "deno compile --allow-net --allow-read --allow-env --env-file=.env.build --allow-write --output Localbox src/main.ts"
},
"compilerOptions": {

6530
src/constants/items.json Normal file

File diff suppressed because it is too large Load diff

140
src/constants/items.ts Normal file
View file

@ -0,0 +1,140 @@
export const shop = {
lastItem: { itemId: "beard2_brown", cost: 20 },
freeItem: { itemId: "goggles_pink", cost: 0 },
nextItem: { itemId: "scout_uniform", cost: 0 },
collection: [
{ itemId: "santa_hat", cost: 100 },
],
};
export const codes = {
rocketsnail: ["viking", "rocket_red"],
andybulletin: "propeller",
cute: "toque_pink",
oommgames: "space_red",
boxcritters3d: ["3d_white", "3d_black"],
goodnight: "sleeping",
madeincanada: "toque_white",
thekeeper: "party_green",
bunnyhug: "hoodie_blue",
greenplumber: "ballcap_green",
duckhunter: "float_pink",
piratepack: "pirate_patch",
pickle: "pickle",
oscarproductions: "guitar_blue",
livestream: "headphones_black",
creative: "headphones_black",
fun: "propeller_pink",
marco: "keytar_red",
snowball: "hoodie_white",
critbits: "ballcap_pink",
esporte: "kit_yellow",
squeeze: "hoodie_orange",
imagination: "box_brown",
sparkle: "propeller_silver",
adventure: "snorkel_blue",
tamago: "sun_square",
redcross: "australian_hat",
discordcritterspt: "headphones_green",
scarletraven: "wings_black",
boxcritterswiki: "paperhat_colour",
wikicritters: "brain_green",
boxcrittersguild: "cardboard_sword_silver",
staysafe: "doctor_mask_blue",
glitter: "pirate_capt_pink",
};
export const throwback = [
"goggles_black",
"pot",
"sombrero_yellow",
"cone",
"toque_purple",
"sun_orange",
"pirate_capt_black",
"lifejacket_red",
"ballcap_black",
"super_cape_red",
"tshirt_white",
"lei_red",
"ballcap_blue",
"viking_blue",
"overalls_orange",
"bunny_blue",
"messenger_brown",
"plaid_black",
"tinfoil_hat",
"monk",
"pirate_hat_black",
"hawaii_orange",
"space_black",
"traffic_cone",
"grass_yellow",
"ushanka",
"bandana_purple",
"ski_suit_blue",
"hotdog",
"space_blue",
"sweater_orange",
"tactical_headset",
"ballcap_yellow",
"ringmaster_suit_red",
"flight_helmet_red",
"rainhat_yellow",
"raincoat_yellow",
"ballcap_red",
"ballerina_pink",
"hawaii_blue",
"stripe_red_white",
"grass_green",
"plaid_blue",
"toque_orange",
"school_pack_orange",
"pirate_crew_blue",
"ringmaster_hat_black",
"super_mask_black",
"pirate_hat_pink",
"rainhat_red",
"tophat_black",
"scarf_red",
"skeleton_body",
"dracula_cloak",
"pumpkin",
"hotdog",
"beard3_black",
"scarf_purple",
"puffy_red",
"blockhead",
"hoodie_purple",
"winter_dress_red",
"bulb_blue",
"tacky_red",
"pirate_bandana_red",
];
export const eventCodes = {
christmas2019: {
jinglebells: "elf_hat_green",
winter: "winter_dress",
joy: "bulb_yellow",
rudolph: "reindeer_head",
elf: "elf_suit_green",
cheer: "bulb_green",
glow: "bulb_red",
rednose: "reindeer_body",
blizzard: "wizard_blizzard",
family: "ornament_red",
shine: "angel_halo",
peace: "angel_wings",
jolly: "santa_beard",
merry: "santa_hat",
christmas: "santa_suit",
boxingday: "onsie_plaid_red",
warm: "sleeping_red",
tacky: "tacky_green",
snow: "goggles_white",
beautiful: "winter_dress_red",
newyear: "tuxedo_black",
"2020": "tophat_black",
},
};

View file

@ -0,0 +1,50 @@
{
"default": {
"start": null,
"end": null
},
"easter2019": {
"start": "04-18-2019",
"end": "04-28-2019"
},
"halloween2019": {
"start": "10-20-2019",
"end": "11-06-2019"
},
"christmas2019": {
"start": "12-05-2019",
"end": "01-05-2020"
},
"lucky2020": {
"start": "03-11-2020",
"end": "04-01-2020"
},
"easter2020": {
"start": "04-10-2020",
"end": "04-19-2020"
},
"grad2020": {
"start": "06-03-2020",
"end": "06-20-2020"
},
"summer2020": {
"start": "07-15-2020",
"end": "08-16-2020"
},
"space": {
"start": "08-16-2020",
"end": "09-04-2020"
},
"halloween2020": {
"start": "10-07-2020",
"end": "11-08-2020"
},
"christmas2020": {
"start": "12-02-2020",
"end": "01-01-2021"
},
"fools2021": {
"start": "03-30-2021",
"end": "04-02-2021"
}
}

91
src/constants/world.ts Normal file
View file

@ -0,0 +1,91 @@
import { PlayerCrumb, Room } from "@/types.ts";
import { indexRoomData } from "@/utils.ts";
export const rooms: Record<string, Record<string, Room>> =
await indexRoomData();
export const spawnRoom = "tavern";
export const players: Record<string, PlayerCrumb> = {
"0": {
"i": "0",
"n": "Huggable",
"c": "huggable",
"x": 1670,
"y": 323,
"r": 180,
"g": [],
"m": "",
"e": "",
"_roomId": "crash_site",
},
};
export const queue: Array<string> = [];
export const roomExits = {
"cellar->tavern": { x: 360, y: 410, r: 0 },
"crash_site->cellar": { x: 615, y: 400, r: 0 },
"shack->port": { x: 550, y: 235, r: 0 },
"jungle->port": { x: 650, y: 230, r: 0 },
"snowman_village->tavern": { x: 563, y: 368, r: 0 },
};
// deno-lint-ignore no-explicit-any
export const npcs: { [key: string]: any } = {
snowman_village: [
{
"i": "NPC0",
"n": "Snow Girl",
"c": "snowgirl",
"x": 1289,
"y": 228,
"r": 180,
"g": [],
"m": "",
"e": "",
},
{
"i": "NPC1",
"n": "Snow Patrol",
"c": "snow_patrol",
"x": 1644,
"y": 221,
"r": 180,
"g": [],
"m": "",
"e": "",
},
{
"i": "NPC2",
"n": "Snow Greeter",
"c": "snow_greeter",
"x": 443,
"y": 317,
"r": 180,
"g": [],
"m": "",
"e": "",
},
{
"i": "NPC3",
"n": "Snow Grandma",
"c": "snowgrandma",
"x": 1938,
"y": 251,
"r": 180,
"g": [],
"m": "",
"e": "",
},
{
"i": "NPC4",
"n": "Snow Keeper",
"c": "snowkeeper",
"x": 893,
"y": 216,
"r": 180,
"g": [],
"m": "",
"e": "",
},
],
};

546
src/io.ts
View file

@ -1,546 +0,0 @@
// deno-lint-ignore-file no-explicit-any
import { Server } from "https://deno.land/x/socket_io@0.2.0/mod.ts";
import { z } from "zod";
import * as world from "../constants/world.ts";
import * as items from "../constants/items.ts";
import * as utils from "../src/utils.ts";
import { CritterId, LocalPlayer, PlayerCrumb, ShopData } from "../src/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 = await utils.verifyJWT(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];
},
};
//@ts-ignore: I will fix the type errors with using a different JWT library eventually
const sub = playerData as {
playerId: string;
nickname: string;
critterId: CritterId;
partyId: string;
persistent: boolean;
mods: Array<string>;
};
if ([
"today2019",
"today2020",
"today2021"
].includes(sub.partyId)) {
console.log('target year:', parseInt(sub.partyId.replace('today', '')));
sub.partyId = utils.getCurrentEvent(parseInt(sub.partyId.replace('today', '')))
};
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("> " + 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] ||
world.rooms[localCrumb._roomId].default;
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(`> ${localPlayer.nickname} sent message:`, 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(`> ${localPlayer.nickname} sent emote:`, 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(`> ${localPlayer.nickname} sent code:`, 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(
"> There is no item in this week's shop with itemId:",
itemId,
);
return;
}
if (
localPlayer.coins >= target.cost &&
!localPlayer.inventory.includes(itemId)
) {
console.log(
"[+] Bought item: " + itemId + " for " + target.cost + " coins",
);
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(Object.keys(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];
}
});
});

View file

@ -8,12 +8,12 @@ import {
} from "https://deno.land/std@0.224.0/path/mod.ts";
import { exists } from "jsr:@std/fs/exists";
import { io } from "./io.ts";
import * as world from "../constants/world.ts";
import { getAccount } from "./utils.ts";
import * as schemas from "./schema.ts";
import * as utils from "./utils.ts";
import parties from "../constants/parties.json" with { type: "json" };
import { io } from "@/socket/index.ts";
import * as world from "@/constants/world.ts";
import { getAccount } from "@/utils.ts";
import * as schemas from "@/schema.ts";
import * as utils from "@/utils.ts";
import parties from "@/constants/parties.json" with { type: "json" };
import { extname } from "https://deno.land/std@0.212.0/path/extname.ts";
import { parseArgs } from "jsr:@std/cli/parse-args";
@ -33,6 +33,7 @@ if (!EXECUTABLE) {
async function serveStatic(req: Request): Promise<Response> {
const url = new URL(req.url);
let pathname = url.pathname;
pathname = decodeURIComponent(pathname);
pathname = pathname.endsWith("/") ? pathname + "index.html" : pathname;
const fsPath = normalize(join(PUBLIC_DIR, pathname));
@ -139,13 +140,17 @@ async function handler(
let partyId = url.searchParams.get("partyId") || "default";
const debug = url.searchParams.has("debug");
if ([
if (
[
"today2019",
"today2020",
"today2021"
].includes(partyId)) {
partyId = utils.getCurrentEvent(parseInt(partyId.replace('today', '')))
};
"today2021",
].includes(partyId)
) {
partyId = utils.getCurrentEvent(
parseInt(partyId.replace("today", "")),
);
}
if (!Object.keys(parties).includes(partyId)) {
return Response.json({
@ -219,14 +224,14 @@ async function handler(
const args = parseArgs(Deno.args, {
string: ["port"],
default: {
port: "3257"
}
port: "3257",
},
});
if (isNaN(Number(args.port))) {
console.log('Port provided is not valid.')
console.log("Port provided is not valid.");
Deno.exit();
};
}
//@ts-ignore: Type issues occuring from upgrading websocket requests to Socket.io
await serve(handler, { port: args.port });

View file

@ -1,11 +1,17 @@
import { z } from "zod";
import parties from "../constants/parties.json" with { type: "json" };
import parties from "@/constants/parties.json" with { type: "json" };
/*
LOGIN API
*/
export const login = z.object({
nickname: z.string().nonempty().max(25),
nickname: z.string()
.transform((s) => s.trim())
.pipe(
z.string()
.min(3, "The nickname must be at least 3 characters long.")
.max(25, "The nickname must be less than 25 characters long."),
),
critterId: z.enum([
"hamster",
"beaver",
@ -18,12 +24,13 @@ export const login = z.object({
"snowgirl",
"snow_patrol",
"snowgrandma",
"alpha_hamster",
]).default("hamster"),
partyId: z.enum([
...Object.keys(parties) as [string, ...string[]],
"today2019",
"today2020",
"today2021"
"today2021",
]).default("default"),
persistent: z.boolean().default(false),
mods: z.array(z.enum(["roomExits"])).default([]),

View file

@ -0,0 +1,155 @@
// deno-lint-ignore-file no-explicit-any
import { Server, Socket } from "https://deno.land/x/socket_io@0.2.0/mod.ts";
import z from "zod";
import * as world from "@/constants/world.ts";
import * as utils from "@/utils.ts";
import * as types from "@/types.ts";
export function listen(
io: Server,
socket: Socket,
ctx: types.SocketHandlerContext,
) {
socket.once("login", async (ticket: string) => {
if (
z.object({
ticket: z.string(),
}).safeParse({ ticket: ticket }).success == false
) return;
let playerData;
try {
playerData = await utils.verifyJWT(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(ctx.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];
},
};
//@ts-ignore: I will fix the type errors with using a different JWT library eventually
const sub = playerData as {
playerId: string;
nickname: string;
critterId: types.CritterId;
partyId: string;
persistent: boolean;
mods: Array<string>;
};
if (
[
"today2019",
"today2020",
"today2021",
].includes(sub.partyId)
) {
console.log("target year:", parseInt(sub.partyId.replace("today", "")));
sub.partyId = utils.getCurrentEvent(
parseInt(sub.partyId.replace("today", "")),
);
}
const persistentAccount = await utils.getAccount(sub.nickname);
if (!sub.persistent || persistentAccount.individual == null) {
ctx.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: sub.mods,
};
if (sub.persistent) {
utils.createAccount(ctx.localPlayer);
ctx.localPlayer = new Proxy<types.LocalPlayer>(
utils.expandAccount(ctx.localPlayer),
handler,
);
}
} else {
persistentAccount.individual.critterId = sub.critterId || "hamster";
persistentAccount.individual._partyId = sub.partyId || "default";
persistentAccount.individual._mods = sub.mods || [];
ctx.localPlayer = new Proxy<types.LocalPlayer>(
utils.expandAccount(persistentAccount.individual),
handler,
);
}
ctx.localPlayer._partyId = socket.handshake.query.get("partyId") ||
"default";
world.queue.splice(world.queue.indexOf(ctx.localPlayer.nickname), 1);
ctx.localCrumb = utils.makeCrumb(ctx.localPlayer, world.spawnRoom);
socket.join(world.spawnRoom);
world.players[ctx.localPlayer.playerId] = ctx.localCrumb;
socket.emit("login", {
player: ctx.localPlayer,
spawnRoom: world.spawnRoom,
});
});
socket.on("beep", () => socket.emit("beep"));
socket.on("disconnect", (reason) => {
if (reason == "server namespace disconnect") return;
if (ctx.localPlayer && ctx.localCrumb) {
io.in(ctx.localCrumb._roomId).emit("R", ctx.localCrumb);
delete world.players[ctx.localPlayer.playerId];
}
});
}

View file

@ -0,0 +1,62 @@
import { Server, Socket } from "https://deno.land/x/socket_io@0.2.0/mod.ts";
import z from "zod";
import * as items from "@/constants/items.ts";
import * as types from "@/types.ts";
export function listen(
_io: Server,
socket: Socket,
ctx: types.SocketHandlerContext,
) {
socket.on("getShop", () => {
const _shopItems = items.shop as unknown as types.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 (!ctx.localPlayer || !ctx.localCrumb) return;
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(
"> There is no item in this week's shop with itemId:",
itemId,
);
return;
}
if (
ctx.localPlayer.coins >= target.cost &&
!ctx.localPlayer.inventory.includes(itemId)
) {
console.log(
"[+] Bought item: " + itemId + " for " + target.cost + " coins",
);
ctx.localPlayer.coins -= target.cost;
ctx.localPlayer.inventory.push(itemId);
socket.emit("buyItem", { itemId: itemId });
socket.emit("updateCoins", { balance: ctx.localPlayer.coins });
}
});
}

View file

@ -0,0 +1,73 @@
import { Server, Socket } from "https://deno.land/x/socket_io@0.2.0/mod.ts";
import z from "zod";
import * as world from "@/constants/world.ts";
import * as utils from "@/utils.ts";
import * as types from "@/types.ts";
export function listen(
io: Server,
socket: Socket,
ctx: types.SocketHandlerContext,
) {
socket.on("moveTo", (x: number, y: number) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
const roomData =
world.rooms[ctx.localCrumb._roomId][ctx.localPlayer._partyId] ||
world.rooms[ctx.localCrumb._roomId].default;
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(
ctx.localPlayer.x,
ctx.localPlayer.y,
x,
y,
);
ctx.localPlayer.x = x;
ctx.localPlayer.y = y;
ctx.localPlayer.rotation = newDirection;
ctx.localCrumb.x = x;
ctx.localCrumb.y = y;
ctx.localCrumb.r = newDirection;
io.in(ctx.localCrumb._roomId).volatile.emit("X", {
i: ctx.localPlayer.playerId,
x: x,
y: y,
r: newDirection,
});
});
socket.on("updateGear", (gear: Array<string>) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
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 (ctx.localPlayer.inventory.includes(itemId)) {
_gear.push(itemId);
}
}
ctx.localPlayer.gear = _gear;
io.in(ctx.localCrumb._roomId).emit("G", {
i: ctx.localPlayer.playerId,
g: ctx.localPlayer.gear,
});
socket.emit("updateGear", ctx.localPlayer.gear);
});
}

View file

@ -0,0 +1,219 @@
// deno-lint-ignore-file no-explicit-any
import { Server, Socket } from "https://deno.land/x/socket_io@0.2.0/mod.ts";
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 * as types from "@/types.ts";
import itemsJSON from "@/constants/items.json" with { type: "json" };
export function listen(
io: Server,
socket: Socket,
ctx: types.SocketHandlerContext,
) {
socket.on("message", (text: string) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
if (
z.object({
text: z.string().nonempty(),
}).safeParse({ text: text }).success == false
) return;
console.log(`> ${ctx.localPlayer.nickname} sent message:`, text);
ctx.localCrumb.m = text;
socket.broadcast.in(ctx.localCrumb._roomId).emit("M", {
i: ctx.localPlayer.playerId,
m: text,
});
setTimeout(() => {
if (ctx.localCrumb!.m != text) return;
ctx.localCrumb!.m = "";
}, 5e3);
});
socket.on("emote", (emote: string) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
if (
z.object({
emote: z.string().nonempty(), // TODO: make this an enum
}).safeParse({ emote: emote }).success == false
) return;
console.log(`> ${ctx.localPlayer.nickname} sent emote:`, emote);
ctx.localCrumb.e = emote;
socket.broadcast.in(ctx.localCrumb._roomId).emit("E", {
i: ctx.localPlayer.playerId,
e: emote,
});
setTimeout(() => {
if (ctx.localCrumb!.e != emote) return;
ctx.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 (!ctx.localPlayer || !ctx.localCrumb) return;
if (
z.object({
command: z.enum([
"pop",
"freeitem",
"tbt",
"darkmode",
"spydar",
"allitems",
]),
}).safeParse({
command: code,
}).success == false
) return;
console.log(`> ${ctx.localPlayer.nickname} sent code:`, code);
const addItem = function (id: string, showGUI: boolean = false) {
if (!ctx.localPlayer!.inventory.includes(id)) {
socket.emit("addItem", { itemId: id, showGUI: showGUI });
ctx.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(
ctx.localPlayer,
items.throwback,
);
if (_throwbackItem) addItem(_throwbackItem, true);
break;
}
case "darkmode": {
addItem("3d_black", true);
break;
}
case "spydar": {
ctx.localPlayer.gear = [
"sun_orange",
"super_mask_black",
"toque_blue",
"dracula_cloak",
"headphones_black",
"hoodie_black",
];
if (ctx.localCrumb._roomId == "tavern") {
ctx.localPlayer.x = 216;
ctx.localPlayer.y = 118;
ctx.localCrumb.x = 216;
ctx.localCrumb.y = 118;
io.in(ctx.localCrumb._roomId).volatile.emit("X", {
i: ctx.localPlayer.playerId,
x: 216,
y: 118,
});
}
io.in(ctx.localCrumb._roomId).emit("G", {
i: ctx.localPlayer.playerId,
g: ctx.localPlayer.gear,
});
socket.emit("updateGear", ctx.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[ctx.localPlayer._partyId] || {})[code];
if (eventItem) addItem(eventItem);
});
socket.on("addIgnore", (playerId: string) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
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) &&
!ctx.localPlayer.ignore.includes(playerId)
) {
ctx.localPlayer.ignore.push(playerId);
}
});
socket.on("attack", (playerId: string) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
if (
z.object({
playerId: z.enum(Object.keys(world.players) as any),
}).strict().safeParse({ playerId: playerId }).success == false
) return;
if (!ctx.localPlayer.gear.includes("bb_beebee")) return;
const monster = Object.values(world.players).find((player) =>
player.i == playerId && player.c == "huggable"
);
if (monster) {
io.in(ctx.localCrumb._roomId).emit("R", monster);
ctx.localPlayer.coins += 10;
socket.emit("updateCoins", { balance: ctx.localPlayer.coins });
delete world.players[playerId];
}
});
}

View file

@ -0,0 +1,116 @@
import { Server, Socket } from "https://deno.land/x/socket_io@0.2.0/mod.ts";
import z from "zod";
import * as world from "@/constants/world.ts";
import * as utils from "@/utils.ts";
import * as types from "@/types.ts";
import parties from "@/constants/parties.json" with { type: "json" };
export function listen(
_io: Server,
socket: Socket,
ctx: types.SocketHandlerContext,
) {
socket.on("joinRoom", (roomId: string) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
if (
z.object({
roomId: z.enum(Object.keys(world.rooms) as [string, ...string[]]),
}).safeParse({ roomId: roomId }).success == false
) return;
const _room = (world.rooms[roomId] || { default: null }).default;
if (!_room) return;
socket.leave(ctx.localCrumb._roomId);
socket.broadcast.in(ctx.localCrumb._roomId).emit("R", ctx.localCrumb);
const modEnabled = (ctx.localPlayer._mods || []).includes("roomExits");
//@ts-ignore: Index type is correct
const correctExit = world.roomExits[ctx.localCrumb._roomId + "->" + roomId];
if (modEnabled && correctExit) {
ctx.localPlayer.x = correctExit.x;
ctx.localPlayer.y = correctExit.y;
ctx.localPlayer.rotation = correctExit.r;
}
if (!modEnabled || !correctExit) {
ctx.localPlayer.x = _room.startX;
ctx.localPlayer.y = _room.startY;
ctx.localPlayer.rotation = _room.startR | 180;
}
ctx.localCrumb = utils.makeCrumb(ctx.localPlayer, roomId);
world.players[ctx.localPlayer.playerId] = ctx.localCrumb;
console.log("> " + ctx.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(ctx.localCrumb._roomId).emit("A", ctx.localCrumb);
});
socket.on("switchParty", (partyId: string) => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
if (
z.object({
partyId: z.enum(Object.keys(parties) as [string, ...string[]]),
}).strict().safeParse({ partyId: partyId }).success == false
) return;
ctx.localPlayer._partyId = partyId;
socket.emit("switchParty");
});
socket.on("trigger", async () => {
if (!ctx.localPlayer || !ctx.localCrumb) return;
const activatedTrigger = await utils.getTrigger(
ctx.localPlayer,
ctx.localCrumb._roomId,
ctx.localPlayer._partyId,
);
if (!activatedTrigger) return;
if (activatedTrigger.hasItems) {
for (const item of activatedTrigger.hasItems) {
if (!ctx.localPlayer.inventory.includes(item)) return;
}
}
if (activatedTrigger.grantItem) {
let items = activatedTrigger.grantItem;
if (typeof items == "string") items = [items];
for (const item of items) {
if (!ctx.localPlayer.inventory.includes(item)) {
socket.emit("addItem", { itemId: item, showGUI: true });
ctx.localPlayer.inventory.push(item);
}
}
}
if (activatedTrigger.addEgg) {
const egg = activatedTrigger.addEgg;
socket.emit("addEgg", egg);
ctx.localPlayer.eggs.push(egg);
}
});
}

22
src/socket/index.ts Normal file
View file

@ -0,0 +1,22 @@
import { Server } from "https://deno.land/x/socket_io@0.2.0/mod.ts";
import * as connection from "./handlers/connection.ts";
import * as player from "./handlers/player.ts";
import * as world from "./handlers/world.ts";
import * as social from "./handlers/social.ts";
import * as economy from "./handlers/economy.ts";
export const io = new Server();
io.on("connection", (socket) => {
const context = {
localPlayer: null,
localCrumb: null,
};
connection.listen(io, socket, context);
player.listen(io, socket, context);
world.listen(io, socket, context);
social.listen(io, socket, context);
economy.listen(io, socket, context);
});

View file

@ -96,45 +96,14 @@ export type ShopData = {
collection: Array<{ itemId: string; cost: number }>;
};
/*
Socket.io
*/
export interface ServerToClientEvents {
login: () => { player: LocalPlayer };
updateGear: () => { i: number; g: Array<string> };
updateCoins: () => { balance: number };
addItem: () => { itemId: string };
addEgg: () => string;
A: () => PlayerCrumb;
R: () => PlayerCrumb;
X: () => { i: number; x: number; y: number };
M: () => { i: number; m: string };
E: () => { i: number; e: string };
G: () => { i: number; g: Array<string> };
}
export interface ClientToServerEvents {
login: (ticket: string) => void;
joinLobby: () => unknown; // Unsure what this is for, I don't think the game had several servers
joinRoom: (
roomId: string,
) => { name: string; roomId: string; playerCrumbs: Array<PlayerCrumb> };
message: (text: string) => void;
emote: (emote: string) => void;
code: (code: string, options?: string) => void;
addIgnore: (id: number) => void;
addFriend: (id: number) => void;
moveTo: (x: number, y: number) => void;
updateCritter: () => unknown; // Unsure of what this is, maybe settings?
updateGear: (gear: Array<string>) => void;
getShop: () => ShopData;
buyItem: (itemId: string) => void;
trigger: () => void;
}
export type PartySchedule = {
[key: string]: {
start: string | null;
end: string | null;
};
};
export type SocketHandlerContext = {
localPlayer: null | LocalPlayer;
localCrumb: null | PlayerCrumb;
};

View file

@ -6,9 +6,9 @@ import {
} from "https://deno.land/std@0.224.0/path/mod.ts";
import { jwtVerify, SignJWT } from "npm:jose@5.9.6";
import { rooms, spawnRoom } from "../constants/world.ts";
import { LocalPlayer, PlayerCrumb, Room } from "./types.ts";
import parties from "../constants/parties.json" with { type: "json" };
import { rooms, spawnRoom } from "@/constants/world.ts";
import { LocalPlayer, PlayerCrumb, Room } from "@/types.ts";
import parties from "@/constants/parties.json" with { type: "json" };
const EXECUTABLE = Deno.env.get("EXECUTABLE") == "true";
const BASE_DIR = EXECUTABLE
@ -183,7 +183,7 @@ export async function updateAccount(
property: string,
value: unknown,
) {
if (["x", "y", "rotation", "_partyId"].includes(property)) return;
if (["x", "y", "rotation", "_partyId", "_mods"].includes(property)) return;
const accounts = await getAccount(nickname);
accounts.individual[property] = value;
@ -245,12 +245,16 @@ export function getCurrentEvent(year: number): string {
const originalStart = new Date(start);
const originalEnd = new Date(end);
const adjustedStart = new Date(year, originalStart.getMonth(), originalStart.getDate());
const adjustedStart = new Date(
year,
originalStart.getMonth(),
originalStart.getDate(),
);
const adjustedEnd = new Date(
// Handle roll over
originalEnd.getFullYear() > originalStart.getFullYear() ? year + 1 : year,
originalEnd.getMonth(),
originalEnd.getDate()
originalEnd.getDate(),
);
if (testDate >= adjustedStart && testDate <= adjustedEnd) {