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});