A Typescript server emulator for Box Critters, a defunct virtual world.
1// deno-lint-ignore-file no-explicit-any
2import { Server, Socket } 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 "@/utils.ts";
8import * as types from "@/types.ts";
9
10import itemsJSON from "@/constants/items.json" with { type: "json" };
11
12export function listen(
13 io: Server,
14 socket: Socket,
15 ctx: types.SocketHandlerContext,
16) {
17 socket.on("message", (text: string) => {
18 if (!ctx.localPlayer || !ctx.localCrumb) return;
19
20 if (
21 z.object({
22 text: z.string().nonempty(),
23 }).safeParse({ text: text }).success == false
24 ) return;
25
26 console.log(`> ${ctx.localPlayer.nickname} sent message:`, text);
27 ctx.localCrumb.m = text;
28
29 socket.broadcast.in(ctx.localCrumb._roomId).emit("M", {
30 i: ctx.localPlayer.playerId,
31 m: text,
32 });
33
34 setTimeout(() => {
35 if (ctx.localCrumb!.m != text) return;
36 ctx.localCrumb!.m = "";
37 }, 5e3);
38 });
39
40 socket.on("emote", (emote: string) => {
41 if (!ctx.localPlayer || !ctx.localCrumb) return;
42
43 if (
44 z.object({
45 emote: z.string().nonempty(), // TODO: make this an enum
46 }).safeParse({ emote: emote }).success == false
47 ) return;
48
49 console.log(`> ${ctx.localPlayer.nickname} sent emote:`, emote);
50 ctx.localCrumb.e = emote;
51
52 socket.broadcast.in(ctx.localCrumb._roomId).emit("E", {
53 i: ctx.localPlayer.playerId,
54 e: emote,
55 });
56
57 setTimeout(() => {
58 if (ctx.localCrumb!.e != emote) return;
59 ctx.localCrumb!.e = "";
60 }, 5e3);
61 });
62
63 // ? Options is specified just because sometimes it is sent, but its always an empty string
64 socket.on("code", (code: string, _options?: string) => {
65 if (!ctx.localPlayer || !ctx.localCrumb) return;
66
67 if (
68 z.object({
69 command: z.enum([
70 "pop",
71 "freeitem",
72 "tbt",
73 "darkmode",
74 "spydar",
75 "allitems",
76 ]),
77 }).safeParse({
78 command: code,
79 }).success == false
80 ) return;
81
82 console.log(`> ${ctx.localPlayer.nickname} sent code:`, code);
83
84 const addItem = function (id: string, showGUI: boolean = false) {
85 if (!ctx.localPlayer!.inventory.includes(id)) {
86 socket.emit("addItem", { itemId: id, showGUI: showGUI });
87 ctx.localPlayer!.inventory.push(id);
88 }
89 };
90
91 // Misc. Codes
92 switch (code) {
93 case "pop": {
94 socket.emit(
95 "pop",
96 Object.values(world.players).filter((critter) =>
97 critter.c != "huggable"
98 ).length,
99 );
100 break;
101 }
102 case "freeitem": {
103 addItem(items.shop.freeItem.itemId, true);
104 break;
105 }
106 case "tbt": {
107 const _throwbackItem = utils.getNewCodeItem(
108 ctx.localPlayer,
109 items.throwback,
110 );
111 if (_throwbackItem) addItem(_throwbackItem, true);
112 break;
113 }
114 case "darkmode": {
115 addItem("3d_black", true);
116 break;
117 }
118 case "spydar": {
119 ctx.localPlayer.gear = [
120 "sun_orange",
121 "super_mask_black",
122 "toque_blue",
123 "dracula_cloak",
124 "headphones_black",
125 "hoodie_black",
126 ];
127
128 if (ctx.localCrumb._roomId == "tavern") {
129 ctx.localPlayer.x = 216;
130 ctx.localPlayer.y = 118;
131
132 ctx.localCrumb.x = 216;
133 ctx.localCrumb.y = 118;
134
135 io.in(ctx.localCrumb._roomId).volatile.emit("X", {
136 i: ctx.localPlayer.playerId,
137 x: 216,
138 y: 118,
139 });
140 }
141
142 io.in(ctx.localCrumb._roomId).emit("G", {
143 i: ctx.localPlayer.playerId,
144 g: ctx.localPlayer.gear,
145 });
146
147 socket.emit("updateGear", ctx.localPlayer.gear);
148 break;
149 }
150 case "allitems": {
151 for (const item of itemsJSON) {
152 addItem(item.itemId, false);
153 }
154 break;
155 }
156 }
157
158 // Item Codes
159 const _itemCodes = items.codes as Record<string, string | Array<string>>;
160 const item = _itemCodes[code];
161
162 if (typeof item == "string") {
163 addItem(item, true);
164 } else if (typeof item == "object") {
165 for (const _ of item) {
166 addItem(_, true);
167 }
168 }
169
170 // Event Codes (eg. Christmas 2019)
171 const _eventItemCodes = items.eventCodes as Record<
172 string,
173 Record<string, string>
174 >;
175 const eventItem = (_eventItemCodes[ctx.localPlayer._partyId] || {})[code];
176 if (eventItem) addItem(eventItem);
177 });
178
179 socket.on("addIgnore", (playerId: string) => {
180 if (!ctx.localPlayer || !ctx.localCrumb) return;
181
182 if (
183 z.object({
184 playerId: z.enum(Object.keys(world.players) as any),
185 }).strict().safeParse({ playerId: playerId }).success == false
186 ) return;
187
188 if (
189 Object.keys(world.players).includes(playerId) &&
190 !ctx.localPlayer.ignore.includes(playerId)
191 ) {
192 ctx.localPlayer.ignore.push(playerId);
193 }
194 });
195
196 socket.on("attack", (playerId: string) => {
197 if (!ctx.localPlayer || !ctx.localCrumb) return;
198
199 if (
200 z.object({
201 playerId: z.enum(Object.keys(world.players) as any),
202 }).strict().safeParse({ playerId: playerId }).success == false
203 ) return;
204
205 if (!ctx.localPlayer.gear.includes("bb_beebee")) return;
206 const monster = Object.values(world.players).find((player) =>
207 player.i == playerId && player.c == "huggable"
208 );
209
210 if (monster) {
211 io.in(ctx.localCrumb._roomId).emit("R", monster);
212
213 ctx.localPlayer.coins += 10;
214 socket.emit("updateCoins", { balance: ctx.localPlayer.coins });
215
216 delete world.players[playerId];
217 }
218 });
219}