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