Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ REDIS_PASSWORD=
# Season Configuration (for default)
CURRENT_SEASON=13

# CORS
# CORS (comma-separated origins are supported)
CORS_ORIGIN=*

# Rate Limiting
Expand Down
16 changes: 15 additions & 1 deletion api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import logger from "node-color-log";

dotenv.config();

const defaultCorsOrigins = [
"http://localhost:4173",
"http://127.0.0.1:4173",
"http://localhost:4174",
"http://127.0.0.1:4174",
"http://localhost:5173",
"http://127.0.0.1:5173",
];

const corsOrigins = (process.env.CORS_ORIGIN || defaultCorsOrigins.join(","))
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);

export const config = {
// Node environment
nodeEnv: process.env.NODE_ENV || "development",
Expand Down Expand Up @@ -33,7 +47,7 @@ export const config = {
currentSeason: parseInt(process.env.CURRENT_SEASON || "13", 10),

// CORS
corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
corsOrigin: corsOrigins.length === 1 ? corsOrigins[0] : corsOrigins,

// Rate limiting
rateLimit: {
Expand Down
206 changes: 206 additions & 0 deletions api/src/game-data/pd2/season-13/Armor.txt

Large diffs are not rendered by default.

1,058 changes: 1,058 additions & 0 deletions api/src/game-data/pd2/season-13/Missiles.txt

Large diffs are not rendered by default.

1,242 changes: 1,242 additions & 0 deletions api/src/game-data/pd2/season-13/MonStats.txt

Large diffs are not rendered by default.

260 changes: 260 additions & 0 deletions api/src/game-data/pd2/season-13/SkillDesc.txt

Large diffs are not rendered by default.

604 changes: 604 additions & 0 deletions api/src/game-data/pd2/season-13/Skills.txt

Large diffs are not rendered by default.

21 changes: 13 additions & 8 deletions api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { createServer, startServer } from "./server";
import { closeAllDatabases } from "./database";
import { characterDB, closeAllDatabases, economyDB } from "./database";
import { initializeRedis, closeRedis } from "./utils/cache";
import { logger as mainLogger } from "./config";

const logger = mainLogger.createNamedLogger("API");

// Initialize Redis
initializeRedis().catch((err) => {
logger.error("Failed to initialize Redis on startup", { error: err });
});
async function main(): Promise<void> {
try {
await Promise.all([characterDB.ready, economyDB.ready, initializeRedis()]);

const app = createServer();
startServer(app);
} catch (error) {
logger.error("Failed to initialize API", { error });
process.exit(1);
}
}

// Create and start the server
const app = createServer();
startServer(app);
void main();

// Graceful shutdown
process.on("SIGTERM", async () => {
Expand Down
28 changes: 19 additions & 9 deletions api/src/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,29 @@ import { startCharacterScraper } from "./jobs/character-scraper";
import { startOnlinePlayersTracker } from "./jobs/online-players-tracker";
import { startLeaderboardUpdater } from "./jobs/leaderboard-updater";
import { logger as mainLogger } from "./config";
import { characterDB, economyDB } from "./database";

const logger = mainLogger.createNamedLogger("Jobs");
/* We use a seperate jobs.ts file instead of placing it all in the main index.ts since we scale to 20 instances of the API in production */

//Start background jobs
startCharacterScraper().catch((error) => {
logger.error("Failed to start character scraper:", error);
});
async function main(): Promise<void> {
await Promise.all([characterDB.ready, economyDB.ready]);

startOnlinePlayersTracker().catch((error) => {
logger.error("Failed to start online players tracker:", error);
});
// Start background jobs after the database schema is ready.
startCharacterScraper().catch((error) => {
logger.error("Failed to start character scraper:", error);
});

startOnlinePlayersTracker().catch((error) => {
logger.error("Failed to start online players tracker:", error);
});

startLeaderboardUpdater().catch((error) => {
logger.error("Failed to start leaderboard updater:", error);
});
}

startLeaderboardUpdater().catch((error) => {
logger.error("Failed to start leaderboard updater:", error);
void main().catch((error) => {
logger.error("Failed to initialize jobs", { error });
process.exit(1);
});
54 changes: 48 additions & 6 deletions api/src/routes/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,46 @@ import {
deleteCachePattern,
} from "../utils/cache";
import { calculateTotalSkills } from "../utils/skill-calculator";
import { CharacterResponse } from "../types";
import { CharacterResponse, CharacterData } from "../types";
import fetch from "node-fetch";
import { calculateDamage } from "../utils/damage-calculator";
import { enrichArmoryPayload } from "../utils/armory-payload";

const logger = mainLogger.createNamedLogger("API");
const router = Router();

function hasDamageCalculatorInput(
data: Partial<CharacterData> | null | undefined
): data is CharacterData {
const character = data?.character;

return Boolean(
character &&
Array.isArray(data?.items) &&
Array.isArray(character.skills) &&
character.class?.name &&
character.attributes
);
}

function attachDamageCalculation(
data: Partial<CharacterData> | null | undefined
) {
if (!hasDamageCalculatorInput(data)) {
return;
}

try {
enrichArmoryPayload(data);
data.damageCalculation = calculateDamage(data);
} catch (error: unknown) {
logger.warn("Damage calculation failed", {
character: data.character?.name,
error: error instanceof Error ? error.message : String(error),
});
}
}

const characterRefreshLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 10,
Expand Down Expand Up @@ -122,9 +156,7 @@ router.get(
const { name } = req.params;
const { gameMode = "softcore", season } = req.query;

const seasonNumber = season
? parseInt(season as string, 10)
: config.currentSeason;
const seasonNumber = season ? parseInt(season as string, 10) : undefined;
const MODES = ["hardcore", "softcore"];

let character = await characterDB.getCharacterByName(
Expand All @@ -146,8 +178,8 @@ router.get(
}
}

//TODO: pass season param and remove this
if (!character) {
// Keep explicit-season links tolerant of the previous default-season behavior.
if (!character && seasonNumber !== undefined) {
for (const gm of MODES) {
character = await characterDB.getCharacterByName(
gm,
Expand All @@ -164,12 +196,15 @@ router.get(
}

// Calculate realStats from items before returning
enrichArmoryPayload(character as unknown as Partial<CharacterData>);
if (character.items && character.items.length > 0) {
// @ts-expect-error - character structure is validated by DB
const statParser = new CharacterStatParser(character);
character.realStats = statParser.parseAndGetCharStats();
}

attachDamageCalculation(character as unknown as Partial<CharacterData>);

res.json(character);
} catch (error: unknown) {
logger.error("Error fetching character", {
Expand Down Expand Up @@ -293,12 +328,15 @@ router.get(
}

// Calculate realStats from items before returning
enrichArmoryPayload(snapshot as unknown as Partial<CharacterData>);
if (snapshot.items && snapshot.items.length > 0) {
// @ts-expect-error - snapshot structure is validated by DB
const statParser = new CharacterStatParser(snapshot);
snapshot.realStats = statParser.parseAndGetCharStats();
}

attachDamageCalculation(snapshot as unknown as Partial<CharacterData>);

res.json(snapshot);
} catch (error: unknown) {
logger.error("Error fetching character snapshot", {
Expand Down Expand Up @@ -724,6 +762,7 @@ router.post("/:name/refresh", characterRefreshLimiter, async (req: Request, res:

// Set lastUpdated and calculate realSkills (same as scraper does)
charData.lastUpdated = now;
enrichArmoryPayload(charData as unknown as Partial<CharacterData>);
charData.realSkills = calculateTotalSkills(
charData as unknown as CharacterResponse
);
Expand Down Expand Up @@ -754,12 +793,15 @@ router.post("/:name/refresh", characterRefreshLimiter, async (req: Request, res:
}

// Calculate realStats from items (same as GET endpoint does)
enrichArmoryPayload(updatedChar as unknown as Partial<CharacterData>);
if (updatedChar.items && updatedChar.items.length > 0) {
// @ts-expect-error - character structure is validated by DB
const statParser = new CharacterStatParser(updatedChar);
updatedChar.realStats = statParser.parseAndGetCharStats();
}

attachDamageCalculation(updatedChar as unknown as Partial<CharacterData>);

logger.info(`Character ${name} successfully refreshed`);

// Return same format as GET endpoint (character directly, not wrapped)
Expand Down
44 changes: 42 additions & 2 deletions api/src/routes/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ import statisticsRoutes from "./statistics";
import healthRoutes from "./health";
import leaderboardRoutes from "./leaderboard";
import { characterDB, economyDB } from "../database";
import { calculateDamage } from "../utils/damage-calculator";

jest.mock("../utils/damage-calculator", () => ({
calculateDamage: jest.fn(() => ({
weaponOptions: [],
skillOptions: [],
playerAuraOptions: [],
transformationOptions: [],
alwaysActiveAuras: [],
profiles: [],
notes: [],
})),
}));

jest.mock("../utils/cache", () => ({
getCacheValue: jest.fn(() => Promise.resolve(undefined)), // Default: no cache hit
Expand Down Expand Up @@ -291,7 +304,7 @@ describe("API Routes", () => {
expect(characterDB.getCharacterByName).toHaveBeenCalledWith(
"softcore",
"TestChar",
12
undefined
);
});

Expand All @@ -306,7 +319,7 @@ describe("API Routes", () => {
expect(characterDB.getCharacterByName).toHaveBeenCalledWith(
"hardcore",
"TestChar",
12
undefined
);
});

Expand Down Expand Up @@ -350,6 +363,33 @@ describe("API Routes", () => {
error: { message: "Failed to fetch character" },
});
});

it("should return character data when damage calculation fails", async () => {
const mockCharacter = {
character: {
name: "TestChar",
level: 90,
class: { name: "Druid" },
attributes: {},
skills: [],
},
items: [],
};

(characterDB.getCharacterByName as jest.Mock).mockResolvedValue(
mockCharacter
);
(calculateDamage as jest.Mock).mockImplementationOnce(() => {
throw new Error("Damage calculation error");
});

const response = await request(app)
.get("/api/v1/characters/TestChar")
.expect(200);

expect(response.body).toEqual(mockCharacter);
expect(calculateDamage).toHaveBeenCalled();
});
});

describe("GET /api/v1/characters/accounts/:accountName", () => {
Expand Down
Loading
Loading