A Typescript server emulator for Box Critters, a defunct virtual world.
at main 7.0 kB view raw
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}