init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
repl.log
|
||||
dist
|
||||
1481
package-lock.json
generated
Normal file
1481
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
22
src/command_catch.ts
Normal 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
7
src/command_exit.ts
Normal 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
16
src/command_explore.ts
Normal 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
12
src/command_help.ts
Normal 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
27
src/command_inspect.ts
Normal 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
27
src/command_map.ts
Normal 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
11
src/command_pokdex.ts
Normal 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
54
src/commands.ts
Normal 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
9
src/main.ts
Normal 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
258
src/pokeapi.ts
Normal 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
27
src/pokecache.test.ts
Normal 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
57
src/pokecache.ts
Normal 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
29
src/repl.test.ts
Normal 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
41
src/repl.ts
Normal 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
36
src/state.ts
Normal 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
15
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user