A Typescript server emulator for Box Critters, a defunct virtual world.
1import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts";
2import {
3 dirname,
4 fromFileUrl,
5 join,
6} from "https://deno.land/std@0.224.0/path/mod.ts";
7import { jwtVerify, SignJWT } from "npm:jose@5.9.6";
8
9import { rooms, spawnRoom } from "@/constants/world.ts";
10import { LocalPlayer, PlayerCrumb, Room } from "@/types.ts";
11import parties from "@/constants/parties.json" with { type: "json" };
12
13const EXECUTABLE = Deno.env.get("EXECUTABLE") == "true";
14const BASE_DIR = EXECUTABLE
15 ? dirname(Deno.execPath())
16 : dirname(dirname(fromFileUrl(Deno.mainModule)));
17const PUBLIC_DIR = join(BASE_DIR, "public");
18
19// deno-lint-ignore no-explicit-any
20export async function createJWT(payload: any): Promise<string> {
21 const jwt = await new SignJWT(payload)
22 .setProtectedHeader({ alg: "HS256" })
23 .setIssuedAt()
24 .setExpirationTime("1h")
25 .sign(new TextEncoder().encode(Deno.env.get("JWT_TOKEN")));
26
27 return jwt;
28}
29
30// deno-lint-ignore no-explicit-any
31export async function verifyJWT(token: string): Promise<any | null> {
32 try {
33 const { payload } = await jwtVerify(
34 token,
35 new TextEncoder().encode(Deno.env.get("JWT_TOKEN")),
36 );
37 return payload;
38 } catch (_e) {
39 return null;
40 }
41}
42
43/** Condenses the local player variable into data that is sufficient enough for other clients */
44export function makeCrumb(player: LocalPlayer, roomId: string): PlayerCrumb {
45 return {
46 i: player.playerId,
47 n: player.nickname,
48 c: player.critterId,
49 x: player.x,
50 y: player.y,
51 r: player.rotation,
52 g: player.gear,
53
54 // message & emote
55 m: "",
56 e: "",
57
58 _roomId: roomId,
59 };
60}
61
62// TODO: use the correct triggers for the active party
63export async function getTrigger(
64 player: LocalPlayer,
65 roomId: string,
66 partyId: string,
67) {
68 const room = rooms[roomId][partyId];
69 if (!room) {
70 console.log(`[!] Cannot find room: "${roomId}@${partyId}"!`);
71 return;
72 }
73
74 try {
75 const treasureBuffer = await Deno.readFile(
76 //@ts-ignore: Deno lint
77 room.media.treasure?.replace("..", "public"),
78 );
79 const treasure = await Image.decode(treasureBuffer);
80 if (!treasure) {
81 throw new Error('Missing map server for room "' + roomId + '"!');
82 }
83
84 const pixel = treasure.getPixelAt(player.x, player.y);
85 const r = (pixel >> 24) & 0xFF,
86 g = (pixel >> 16) & 0xFF,
87 b = (pixel >> 8) & 0xFF;
88 const hexCode = ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0");
89
90 const trigger = room.triggers.find((trigger) => trigger.hex == hexCode);
91 if (trigger) {
92 return trigger.server;
93 } else {
94 return null;
95 }
96 } catch (e) {
97 console.warn("[!] Caught error while checking for activated trigger.", e);
98 }
99}
100
101export function getNewCodeItem(player: LocalPlayer, items: Array<string>) {
102 const itemsSet = new Set(player.inventory);
103 const available = items.filter((item) => !itemsSet.has(item));
104 return available.length === 0
105 ? null
106 : available[Math.floor(Math.random() * available.length)];
107}
108
109/**
110 * Indexes the /media/rooms directory for all versions of all rooms
111 * @returns All versions of every room
112 */
113export async function indexRoomData() {
114 const _roomData: Record<string, Record<string, Room>> = {};
115
116 const basePath = join(PUBLIC_DIR, "media", "rooms");
117 const _rooms = Deno.readDir(basePath);
118
119 for await (const room of _rooms) {
120 if (room.isDirectory) {
121 _roomData[room.name] = {};
122 const roomPath = join(basePath, room.name);
123 const versions = Deno.readDir(roomPath);
124 for await (const version of versions) {
125 if (version.isDirectory) {
126 const versionPath = join(roomPath, version.name, "data.json");
127 try {
128 const data = await Deno.readTextFile(versionPath);
129 _roomData[room.name][version.name] = JSON.parse(data);
130 } catch (_) {
131 console.log(
132 `[!] "${room.name}@${version.name}" is missing a data.json file`,
133 );
134 }
135 }
136 }
137 }
138 }
139
140 return _roomData;
141}
142
143export async function getAccount(nickname?: string) {
144 let accounts = [];
145 try {
146 const data = await Deno.readTextFile("accounts.json");
147 accounts = JSON.parse(data);
148 } catch (error) {
149 if (error instanceof Deno.errors.NotFound) {
150 console.log("Persistent login JSON is missing, using blank JSON array..");
151 accounts = [];
152 } else {
153 console.log(
154 "[!] Failure to fetch persistent login data with nickname: ",
155 nickname,
156 );
157 throw error;
158 }
159 }
160
161 if (nickname) {
162 const existingAccount = accounts.find((player: { nickname: string }) =>
163 player.nickname == nickname
164 );
165 if (existingAccount) {
166 return {
167 all: accounts,
168 individual: existingAccount,
169 };
170 } else {
171 return {
172 all: accounts,
173 individual: null,
174 };
175 }
176 } else {
177 return accounts;
178 }
179}
180
181export async function updateAccount(
182 nickname: string,
183 property: string,
184 value: unknown,
185) {
186 if (["x", "y", "rotation", "_partyId", "_mods"].includes(property)) return;
187 const accounts = await getAccount(nickname);
188
189 accounts.individual[property] = value;
190 await Deno.writeTextFile(
191 "accounts.json",
192 JSON.stringify(accounts.all, null, 2),
193 );
194}
195
196export function trimAccount(player: LocalPlayer) {
197 for (
198 const key of [
199 "critterId",
200 "x",
201 "y",
202 "rotation",
203 "_partyId",
204 "_mods",
205 ]
206 ) {
207 delete player[key];
208 }
209 return player;
210}
211
212export function expandAccount(player: LocalPlayer) {
213 const defaultPos = rooms[spawnRoom].default;
214 player.x = defaultPos.startX;
215 player.y = defaultPos.startY;
216 player.rotation = defaultPos.startR;
217 return player;
218}
219
220export function getDirection(
221 x: number,
222 y: number,
223 targetX: number,
224 targetY: number,
225) {
226 const a = Math.floor((180 * Math.atan2(targetX - x, y - targetY)) / Math.PI);
227 return a < 0 ? a + 360 : a;
228}
229
230export async function createAccount(player: LocalPlayer) {
231 const accounts = await getAccount();
232 accounts.push(trimAccount(player));
233
234 await Deno.writeTextFile("accounts.json", JSON.stringify(accounts, null, 2));
235}
236
237export function getCurrentEvent(year: number): string {
238 const today = new Date();
239 const testDate = new Date(year, today.getMonth(), today.getDate());
240
241 //@ts-ignore: Types are bugging out here for absolutely no reason
242 for (const [eventId, { start, end }] of Object.entries(parties)) {
243 if (!start || !end) continue;
244
245 const originalStart = new Date(start);
246 const originalEnd = new Date(end);
247
248 const adjustedStart = new Date(
249 year,
250 originalStart.getMonth(),
251 originalStart.getDate(),
252 );
253 const adjustedEnd = new Date(
254 // Handle roll over
255 originalEnd.getFullYear() > originalStart.getFullYear() ? year + 1 : year,
256 originalEnd.getMonth(),
257 originalEnd.getDate(),
258 );
259
260 if (testDate >= adjustedStart && testDate <= adjustedEnd) {
261 return eventId;
262 }
263 }
264
265 return "default";
266}