This commit is contained in:
2025-09-08 13:22:48 +03:00
commit a92aa1ed6c
20 changed files with 2153 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
repl.log
dist

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.15.0

1481
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "pokedex",
"version": "1.0.0",
"description": "",
"main": "dist/main.js",
"type": "module",
"scripts": {
"build": "npx tsc",
"start": "node dist/main.js",
"dev": "npx tsc && node dist/main.js",
"test": "vitest --run"
},
"keywords": [],
"author": "",
"devDependencies": {
"@types/node": "^22.9.1",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}

22
src/command_catch.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { State } from "./state.js";
export async function commandCatch(state: State, ...args: string[]) {
if (args.length !== 1) {
throw new Error("you must provide a pokemon name");
}
const name = args[0];
const pokemon = await state.pokeAPI.fetchPokemon(name);
console.log(`Throwing a Pokeball at ${pokemon.name}...`);
const res = Math.floor(Math.random() * pokemon.base_experience);
if (res > 40) {
console.log(`${pokemon.name} escaped!`);
return;
}
console.log(`${pokemon.name} was caught!`);
console.log("You may now inspect it with the inspect command.");
state.caughtPokemon[pokemon.name] = pokemon;
}

7
src/command_exit.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { State } from "./state.js";
export async function commandExit(state: State) {
console.log("Closing the Pokedex... Goodbye!");
state.readline.close();
process.exit(0);
}

16
src/command_explore.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { State } from "./state.js";
export async function commandExplore(state: State, ...args: string[]) {
if (args.length !== 1) {
throw new Error("you must provide a location name");
}
const name = args[0];
const location = await state.pokeAPI.fetchLocation(name);
console.log(`Exploring ${name}...`);
console.log("Found Pokemon:");
for (const enc of location.pokemon_encounters) {
console.log(` - ${enc.pokemon.name}`);
}
}

12
src/command_help.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { State } from "./state.js";
export async function commandHelp(state: State) {
console.log();
console.log("Welcome to the Pokedex!");
console.log("Usage:");
console.log();
for (const cmd of Object.values(state.commands)) {
console.log(`${cmd.name}: ${cmd.description}`);
}
console.log();
}

27
src/command_inspect.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { State } from "./state.js";
export async function commandInspect(state: State, ...args: string[]) {
if (args.length !== 1) {
throw new Error("you must provide a pokemon name");
}
const name = args[0];
const pokemon = await state.caughtPokemon[name];
if (!pokemon) {
console.log(`you have not caught that pokemon`)
return;
}
console.log(`Name: ${pokemon.name}`);
console.log(`Height: ${pokemon.height}`);
console.log(`Weight: ${pokemon.weight}`);
console.log(`Stats:`);
for (let stat of pokemon.stats) {
console.log(` ${stat.stat.name}: ${stat.base_stat}`);
}
console.log(`Types:`);
for (let type of pokemon.types) {
console.log(` - ${type.type.name}`);
}
}

27
src/command_map.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { State } from "./state.js";
export async function commandMapForward(state: State) {
const locations = await state.pokeAPI.fetchLocations(state.nextLocationsURL);
state.nextLocationsURL = locations.next;
state.prevLocationsURL = locations.previous;
for (const loc of locations.results) {
console.log(loc.name);
}
}
export async function commandMapBack(state: State) {
if (!state.prevLocationsURL) {
throw new Error("you're on the first page");
}
const locations = await state.pokeAPI.fetchLocations(state.prevLocationsURL);
state.nextLocationsURL = locations.next;
state.prevLocationsURL = locations.previous;
for (const loc of locations.results) {
console.log(loc.name);
}
}

11
src/command_pokdex.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { State } from "./state.js";
export async function commandPokedex(state: State) {
const pokemon = await state.caughtPokemon;
console.log('Your pokedex:');
for (const poke in pokemon) {
console.log(` - ${poke}`);
}
}

54
src/commands.ts Normal file
View File

@@ -0,0 +1,54 @@
import { commandHelp } from "./command_help.js";
import { commandExit } from "./command_exit.js";
import { commandMapForward, commandMapBack } from "./command_map.js";
import { commandExplore } from "./command_explore.js";
import { commandCatch } from "./command_catch.js";
import type { CLICommand } from "./state.js";
import { commandInspect } from "./command_inspect.js";
import { commandPokedex } from "./command_pokdex.js";
export function getCommands(): Record<string, CLICommand> {
return {
help: {
name: "help",
description: "Displays a help message",
callback: commandHelp,
},
exit: {
name: "exit",
description: "Exit the Pokedex",
callback: commandExit,
},
map: {
name: "map",
description: "Get the next page of locations",
callback: commandMapForward,
},
mapb: {
name: "mapb",
description: "Get the previous page of locations",
callback: commandMapBack,
},
explore: {
name: "explore <location_name>",
description: "Explore a location",
callback: commandExplore,
},
catch: {
name: "catch <pokemon_name>",
description: "Attempt to catch a pokemon",
callback: commandCatch,
},
inspect: {
name: "inspect <pokemon_name>",
description: "Inspect caught pokemon",
callback: commandInspect,
},
pokedex: {
name: "pokedex",
description: "List your caught pokemon",
callback: commandPokedex,
},
};
}

9
src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { startREPL } from "./repl.js";
import { initState } from "./state.js";
async function main() {
const state = initState(1000 * 60 * 5); // 5 minutes
await startREPL(state);
}
main();

258
src/pokeapi.ts Normal file
View File

@@ -0,0 +1,258 @@
import { Cache } from "./pokecache.js";
export class PokeAPI {
private static readonly baseURL = "https://pokeapi.co/api/v2";
private cache: Cache;
constructor(cacheInterval: number) {
this.cache = new Cache(cacheInterval);
}
closeCache() {
this.cache.stopReapLoop();
}
async fetchLocations(pageURL?: string): Promise<ShallowLocations> {
const url = pageURL || `${PokeAPI.baseURL}/location-area`;
const cached = this.cache.get<ShallowLocations>(url);
if (cached) {
return cached;
}
try {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
const locations: ShallowLocations = await resp.json();
this.cache.add(url, locations);
return locations;
} catch (e) {
throw new Error(`Error fetching locations: ${(e as Error).message}`);
}
}
async fetchLocation(locationName: string): Promise<Location> {
const url = `${PokeAPI.baseURL}/location-area/${locationName}`;
const cached = this.cache.get<Location>(url);
if (cached) {
return cached;
}
try {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
const location: Location = await resp.json();
this.cache.add(url, location);
return location;
} catch (e) {
throw new Error(
`Error fetching location '${locationName}': ${(e as Error).message}`,
);
}
}
async fetchPokemon(pokemonName: string): Promise<Pokemon> {
const url = `${PokeAPI.baseURL}/pokemon/${pokemonName}`;
const cached = this.cache.get<Pokemon>(url);
if (cached) {
return cached;
}
try {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
const pokemon: Pokemon = await resp.json();
this.cache.add(url, pokemon);
return pokemon;
} catch (e) {
throw new Error(
`Error fetching pokemon '${pokemonName}': ${(e as Error).message}`,
);
}
}
}
export type ShallowLocations = {
count: number;
next: string;
previous: string;
results: {
name: string;
url: string;
}[];
};
export type Location = {
encounter_method_rates: {
encounter_method: {
name: string;
url: string;
};
version_details: {
rate: number;
version: {
name: string;
url: string;
};
}[];
}[];
game_index: number;
id: number;
location: {
name: string;
url: string;
};
name: string;
names: {
language: {
name: string;
url: string;
};
name: string;
}[];
pokemon_encounters: {
pokemon: {
name: string;
url: string;
};
version_details: {
encounter_details: {
chance: number;
condition_values: any[];
max_level: number;
method: {
name: string;
url: string;
};
min_level: number;
}[];
max_chance: number;
version: {
name: string;
url: string;
};
}[];
}[];
};
export type Pokemon = {
abilities: {
ability: {
name: string;
url: string;
};
is_hidden: boolean;
slot: number;
}[];
base_experience: number;
forms: {
name: string;
url: string;
}[];
game_indices: {
game_index: number;
version: {
name: string;
url: string;
};
}[];
height: number;
held_items: any[];
id: number;
is_default: boolean;
location_area_encounters: string;
moves: {
move: {
name: string;
url: string;
};
version_group_details: {
level_learned_at: number;
move_learn_method: {
name: string;
url: string;
};
version_group: {
name: string;
url: string;
};
}[];
}[];
name: string;
order: number;
past_types: any[];
species: {
name: string;
url: string;
};
sprites: {
back_default: string;
back_female: any;
back_shiny: string;
back_shiny_female: any;
front_default: string;
front_female: any;
front_shiny: string;
front_shiny_female: any;
other: {
dream_world: {
front_default: string;
front_female: any;
};
home: {
front_default: string;
front_female: any;
front_shiny: string;
front_shiny_female: any;
};
official_artwork: {
front_default: string;
front_shiny: string;
};
};
versions: {
[generation: string]: {
[game: string]: {
back_default: string;
back_female?: any;
back_shiny: string;
back_shiny_female?: any;
front_default: string;
front_female?: any;
front_shiny: string;
front_shiny_female?: any;
};
};
};
};
stats: {
base_stat: number;
effort: number;
stat: {
name: string;
url: string;
};
}[];
types: {
slot: number;
type: {
name: string;
url: string;
};
}[];
weight: number;
};

27
src/pokecache.test.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Cache } from "./pokecache.js";
import { test, expect } from "vitest";
test.concurrent.each([
{
key: "https://example.com",
val: "testdata",
interval: 500, // 0.5 seconds
},
{
key: "https://example.com/path",
val: "moretestdata",
interval: 1000, // 1 second
},
])("Test Caching $interval ms", async ({ key, val, interval }) => {
const cache = new Cache(interval);
cache.add(key, val);
const cached = cache.get(key);
expect(cached).toBe(val);
await new Promise((resolve) => setTimeout(resolve, interval * 2));
const reaped = cache.get(key);
expect(reaped).toBe(undefined);
cache.stopReapLoop();
});

57
src/pokecache.ts Normal file
View File

@@ -0,0 +1,57 @@
type CacheEntry<T> = {
createdAt: number;
val: T;
};
export class Cache {
#cache = new Map<string, CacheEntry<any>>();
#reapIntervalId: NodeJS.Timeout | undefined = undefined;
#interval: number;
constructor(interval: number) {
this.#interval = interval;
this.#startReapLoop();
}
add<T>(key: string, value: T) {
const entry: CacheEntry<T> = {
createdAt: Date.now(),
val: value,
};
this.#cache.set(key, entry);
}
get<T>(key: string) {
const entry = this.#cache.get(key);
if (entry !== undefined) {
if (Date.now() - entry.createdAt > this.#interval) {
this.#cache.delete(key);
return undefined;
}
return entry.val as T;
}
return undefined;
}
#startReapLoop() {
this.#reapIntervalId = setInterval(() => {
this.#reap();
}, this.#interval);
}
#reap() {
const now = Date.now();
for (const [key, entry] of this.#cache) {
if (now - entry.createdAt > this.#interval) {
this.#cache.delete(key);
}
}
}
stopReapLoop() {
if (this.#reapIntervalId) {
clearInterval(this.#reapIntervalId);
this.#reapIntervalId = undefined;
}
}
}

29
src/repl.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { cleanInput } from "./repl.js";
import { describe, expect, test } from "vitest";
describe.each([
{
input: " ",
expected: [],
},
{
input: " hello ",
expected: ["hello"],
},
{
input: " hello world ",
expected: ["hello", "world"],
},
{
input: " HellO World ",
expected: ["hello", "world"],
},
])("cleanInput($input)", ({ input, expected }) => {
test(`Expected: ${expected}`, () => {
const actual = cleanInput(input);
expect(actual).toHaveLength(expected.length);
for (const i in expected) {
expect(actual[i]).toBe(expected[i]);
}
});
});

41
src/repl.ts Normal file
View File

@@ -0,0 +1,41 @@
import { State } from "./state.js";
export async function startREPL(state: State) {
state.readline.prompt();
state.readline.on("line", async (input) => {
const words = cleanInput(input);
if (words.length === 0) {
state.readline.prompt();
return;
}
const commandName = words[0];
const args = words.slice(1);
const cmd = state.commands[commandName];
if (!cmd) {
console.log(
`Unknown command: "${commandName}". Type "help" for a list of commands.`,
);
state.readline.prompt();
return;
}
try {
await cmd.callback(state, ...args);
} catch (e) {
console.log((e as Error).message);
}
state.readline.prompt();
});
}
export function cleanInput(input: string): string[] {
return input
.toLowerCase()
.trim()
.split(" ")
.filter((word) => word !== "");
}

36
src/state.ts Normal file
View File

@@ -0,0 +1,36 @@
import { createInterface, type Interface } from "readline";
import { getCommands } from "./commands.js";
import { PokeAPI } from "./pokeapi.js";
import type { Pokemon } from "./pokeapi.js";
export type CLICommand = {
name: string;
description: string;
callback: (state: State, ...args: string[]) => Promise<void>;
};
export type State = {
readline: Interface;
commands: Record<string, CLICommand>;
pokeAPI: PokeAPI;
nextLocationsURL: string;
prevLocationsURL: string;
caughtPokemon: Record<string, Pokemon>;
};
export function initState(cacheInterval: number) {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
prompt: "pokedex > ",
});
return {
readline: rl,
commands: getCommands(),
pokeAPI: new PokeAPI(cacheInterval),
nextLocationsURL: "",
prevLocationsURL: "",
caughtPokemon: {},
};
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "esnext",
"module": "esnext",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"moduleResolution": "Node",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["./src/**/*.ts"],
"exclude": ["node_modules"]
}