This commit is contained in:
starified
2026-04-21 22:03:19 -04:00
parent 36e2d11f2e
commit 08bf320b57
4681 changed files with 566542 additions and 0 deletions

604
server/routes/players.js Normal file
View File

@@ -0,0 +1,604 @@
const express = require("express");
const { pool } = require("../db");
const {
GAMEMODES,
USERNAME_REGEX,
REGIONS,
avatarUrl,
isValidTier,
isValidGamemode,
isValidRegion,
normalizeGamemode,
normalizeRegion,
normalizeTier,
tierToPoints,
} = require("../config");
const { ValidationError, validateRequest } = require("../middleware/validation");
const { requireWriteApiKey } = require("../middleware/security");
const { sendError } = require("../utils/http");
const router = express.Router();
const PER_PAGE = 20;
const TIER_POINTS_SQL = `
CASE pr.tier
WHEN 'HT1' THEN 60
WHEN 'LT1' THEN 45
WHEN 'HT2' THEN 30
WHEN 'LT2' THEN 20
WHEN 'HT3' THEN 10
WHEN 'LT3' THEN 6
WHEN 'HT4' THEN 4
WHEN 'LT4' THEN 3
WHEN 'HT5' THEN 2
WHEN 'LT5' THEN 1
ELSE 0
END`;
const schemas = {
playersQuery: {
query: {
fields: {
page: { type: "int", min: 1, max: 50000 },
},
allowUnknown: false,
},
},
usernameParam: {
params: {
fields: {
username: {
type: "string",
required: true,
minLength: 3,
maxLength: 16,
pattern: USERNAME_REGEX,
trim: true,
},
},
allowUnknown: false,
},
},
createPlayerBody: {
body: {
fields: {
username: {
type: "string",
required: true,
minLength: 3,
maxLength: 16,
pattern: USERNAME_REGEX,
trim: true,
},
region: {
type: "string",
required: false,
minLength: 2,
maxLength: 2,
toUpperCase: true,
},
},
allowUnknown: false,
},
},
updateRegionBody: {
body: {
fields: {
region: {
type: "string",
required: true,
minLength: 2,
maxLength: 2,
toUpperCase: true,
},
},
allowUnknown: false,
},
},
updateRankBody: {
body: {
fields: {
gamemode: {
type: "string",
required: true,
minLength: 3,
maxLength: 32,
toLowerCase: true,
},
tier: {
type: "string",
required: true,
minLength: 3,
maxLength: 3,
toUpperCase: true,
},
region: {
type: "string",
required: false,
minLength: 2,
maxLength: 2,
toUpperCase: true,
},
},
allowUnknown: false,
},
},
removeRankParams: {
params: {
fields: {
username: {
type: "string",
required: true,
minLength: 3,
maxLength: 16,
pattern: USERNAME_REGEX,
trim: true,
},
gamemode: {
type: "string",
required: true,
minLength: 3,
maxLength: 32,
toLowerCase: true,
},
},
allowUnknown: false,
},
},
searchQuery: {
query: {
fields: {
q: {
type: "string",
required: false,
minLength: 1,
maxLength: 16,
pattern: /^[A-Za-z0-9_]+$/,
trim: true,
},
},
allowUnknown: false,
},
},
};
function validateOrReply(req, res, spec) {
try {
return validateRequest(req, spec);
} catch (error) {
if (error instanceof ValidationError) {
sendError(res, 400, "VALIDATION_ERROR", error.message, error.details);
return null;
}
sendError(res, 400, "VALIDATION_ERROR", "Invalid request payload.");
return null;
}
}
function emptyTierMap() {
return Object.fromEntries(GAMEMODES.map((mode) => [mode, null]));
}
function hydratePlayersFromRows(playerRows, rankRows, startPosition = 0) {
const byId = new Map();
for (let i = 0; i < playerRows.length; i += 1) {
const row = playerRows[i];
byId.set(row.id, {
id: row.id,
username: row.username,
region: row.region,
avatarUrl: avatarUrl(row.username),
createdAt: row.created_at,
tiers: emptyTierMap(),
totalPoints: Number(row.total_points || 0),
position: startPosition + i + 1,
});
}
for (const row of rankRows) {
const target = byId.get(row.player_id);
if (!target) {
continue;
}
target.tiers[row.gamemode] = row.tier;
}
return Array.from(byId.values());
}
async function fetchRanksForPlayerIds(playerIds) {
if (!playerIds.length) {
return [];
}
const [rankRows] = await pool.query(
"SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id IN (?)",
[playerIds]
);
return rankRows;
}
router.get("/players", async (req, res) => {
const validated = validateOrReply(req, res, schemas.playersQuery);
if (!validated) {
return;
}
try {
const page = validated.query.page || 1;
const [orderedPlayers] = await pool.query(
`SELECT
p.id,
p.username,
p.region,
p.created_at,
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
FROM players p
LEFT JOIN player_ranks pr ON pr.player_id = p.id
GROUP BY p.id, p.username, p.region, p.created_at
ORDER BY total_points DESC, p.username ASC`
);
const total = orderedPlayers.length;
const totalPages = Math.max(Math.ceil(total / PER_PAGE), 1);
const start = (page - 1) * PER_PAGE;
const pageRows = orderedPlayers.slice(start, start + PER_PAGE);
const rankRows = await fetchRanksForPlayerIds(pageRows.map((row) => row.id));
const data = hydratePlayersFromRows(pageRows, rankRows, start);
return res.json({
data,
pagination: {
page,
perPage: PER_PAGE,
total,
totalPages,
},
});
} catch (error) {
console.error("GET /api/players failed:", error?.message || error);
return sendError(res, 500, "PLAYERS_FETCH_FAILED", "Failed to fetch players");
}
});
router.get("/players/all", async (req, res) => {
try {
const [orderedPlayers] = await pool.query(
`SELECT
p.id,
p.username,
p.region,
p.created_at,
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
FROM players p
LEFT JOIN player_ranks pr ON pr.player_id = p.id
GROUP BY p.id, p.username, p.region, p.created_at
ORDER BY total_points DESC, p.username ASC`
);
const rankRows = await fetchRanksForPlayerIds(orderedPlayers.map((row) => row.id));
const data = hydratePlayersFromRows(orderedPlayers, rankRows, 0);
return res.json({ data });
} catch (error) {
console.error("GET /api/players/all failed:", error?.message || error);
return sendError(res, 500, "PLAYERS_FETCH_FAILED", "Failed to fetch all players");
}
});
router.get("/players/:username", async (req, res) => {
const validated = validateOrReply(req, res, schemas.usernameParam);
if (!validated) {
return;
}
try {
const username = validated.params.username;
const [rows] = await pool.query(
`SELECT
p.id,
p.username,
p.region,
p.created_at,
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
FROM players p
LEFT JOIN player_ranks pr ON pr.player_id = p.id
WHERE LOWER(p.username) = LOWER(?)
GROUP BY p.id, p.username, p.region, p.created_at
LIMIT 1`,
[username]
);
if (rows.length === 0) {
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
}
const player = rows[0];
const [rankRows] = await pool.query(
"SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id = ?",
[player.id]
);
const [positionRows] = await pool.query(
`SELECT 1 + COUNT(*) AS position
FROM (
SELECT p.id, p.username, COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
FROM players p
LEFT JOIN player_ranks pr ON pr.player_id = p.id
GROUP BY p.id, p.username
) ranked
WHERE ranked.total_points > ?
OR (ranked.total_points = ? AND ranked.username < ?)`,
[player.total_points, player.total_points, player.username]
);
const tiers = emptyTierMap();
for (const row of rankRows) {
tiers[row.gamemode] = row.tier;
}
return res.json({
id: player.id,
username: player.username,
region: player.region,
avatarUrl: avatarUrl(player.username),
createdAt: player.created_at,
tiers,
totalPoints: Number(player.total_points || 0),
position: Number(positionRows[0]?.position || 1),
});
} catch (error) {
console.error("GET /api/players/:username failed:", error?.message || error);
return sendError(res, 500, "PLAYER_FETCH_FAILED", "Failed to fetch player profile");
}
});
router.post("/player", requireWriteApiKey, async (req, res) => {
const validated = validateOrReply(req, res, schemas.createPlayerBody);
if (!validated) {
return;
}
try {
const username = validated.body.username;
const region = normalizeRegion(validated.body.region || "NA");
if (!isValidRegion(region)) {
return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`);
}
const [insertResult] = await pool.query(
"INSERT INTO players (username, region) VALUES (?, ?)",
[username, region]
);
return res.status(201).json({
message: "Player created",
id: insertResult.insertId,
username,
region,
});
} catch (error) {
if (error && error.code === "ER_DUP_ENTRY") {
return sendError(res, 409, "USERNAME_EXISTS", "Username already exists");
}
console.error("POST /api/player failed:", error?.message || error);
return sendError(res, 500, "PLAYER_CREATE_FAILED", "Failed to create player");
}
});
router.put("/player/:username/region", requireWriteApiKey, async (req, res) => {
const paramValidated = validateOrReply(req, res, schemas.usernameParam);
if (!paramValidated) {
return;
}
const bodyValidated = validateOrReply(req, res, schemas.updateRegionBody);
if (!bodyValidated) {
return;
}
const username = paramValidated.params.username;
const region = normalizeRegion(bodyValidated.body.region);
if (!isValidRegion(region)) {
return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`);
}
try {
const [result] = await pool.query(
"UPDATE players SET region = ? WHERE LOWER(username) = LOWER(?)",
[region, username]
);
if (result.affectedRows === 0) {
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
}
return res.json({ message: "Region updated", username, region });
} catch (error) {
console.error("PUT /api/player/:username/region failed:", error?.message || error);
return sendError(res, 500, "REGION_UPDATE_FAILED", "Failed to update region");
}
});
router.delete("/player/:username", requireWriteApiKey, async (req, res) => {
const validated = validateOrReply(req, res, schemas.usernameParam);
if (!validated) {
return;
}
try {
const username = validated.params.username;
const [result] = await pool.query("DELETE FROM players WHERE LOWER(username) = LOWER(?)", [username]);
if (result.affectedRows === 0) {
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
}
return res.json({ message: "Player removed", username });
} catch (error) {
console.error("DELETE /api/player/:username failed:", error?.message || error);
return sendError(res, 500, "PLAYER_DELETE_FAILED", "Failed to remove player");
}
});
router.put("/player/:username/rank", requireWriteApiKey, async (req, res) => {
const paramValidated = validateOrReply(req, res, schemas.usernameParam);
if (!paramValidated) {
return;
}
const bodyValidated = validateOrReply(req, res, schemas.updateRankBody);
if (!bodyValidated) {
return;
}
const username = paramValidated.params.username;
const gamemode = normalizeGamemode(bodyValidated.body.gamemode);
const tier = normalizeTier(bodyValidated.body.tier);
const requestedRegion = bodyValidated.body.region;
const region = requestedRegion == null ? null : normalizeRegion(requestedRegion);
if (!isValidGamemode(gamemode)) {
return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode");
}
if (!isValidTier(tier)) {
return sendError(res, 400, "VALIDATION_ERROR", "Invalid tier");
}
if (region !== null && !isValidRegion(region)) {
return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`);
}
try {
const [playerRows] = await pool.query(
"SELECT id, username, region FROM players WHERE LOWER(username) = LOWER(?) LIMIT 1",
[username]
);
if (playerRows.length === 0) {
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
}
const player = playerRows[0];
if (region !== null && player.region !== region) {
await pool.query("UPDATE players SET region = ? WHERE id = ?", [region, player.id]);
player.region = region;
}
await pool.query(
`INSERT INTO player_ranks (player_id, gamemode, tier)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE tier = VALUES(tier), updated_at = CURRENT_TIMESTAMP`,
[player.id, gamemode, tier]
);
return res.json({
message: "Rank updated",
username: player.username,
region: player.region,
gamemode,
tier,
});
} catch (error) {
console.error("PUT /api/player/:username/rank failed:", error?.message || error);
return sendError(res, 500, "RANK_UPDATE_FAILED", "Failed to update rank");
}
});
router.delete("/player/:username/rank/:gamemode", requireWriteApiKey, async (req, res) => {
const validated = validateOrReply(req, res, schemas.removeRankParams);
if (!validated) {
return;
}
const username = validated.params.username;
const gamemode = normalizeGamemode(validated.params.gamemode);
if (!isValidGamemode(gamemode)) {
return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode");
}
try {
const [playerRows] = await pool.query(
"SELECT id, username FROM players WHERE LOWER(username) = LOWER(?) LIMIT 1",
[username]
);
if (playerRows.length === 0) {
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
}
const [result] = await pool.query(
"DELETE FROM player_ranks WHERE player_id = ? AND gamemode = ?",
[playerRows[0].id, gamemode]
);
if (result.affectedRows === 0) {
return sendError(res, 404, "RANK_NOT_FOUND", "No rank found for this gamemode");
}
return res.json({ message: "Rank removed", username: playerRows[0].username, gamemode });
} catch (error) {
console.error("DELETE /api/player/:username/rank/:gamemode failed:", error?.message || error);
return sendError(res, 500, "RANK_DELETE_FAILED", "Failed to remove rank");
}
});
router.get("/search", async (req, res) => {
const validated = validateOrReply(req, res, schemas.searchQuery);
if (!validated) {
return;
}
try {
const q = validated.query.q || "";
if (!q) {
return res.json({ data: [] });
}
const [matches] = await pool.query(
`SELECT
p.id,
p.username,
p.region,
p.created_at,
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
FROM players p
LEFT JOIN player_ranks pr ON pr.player_id = p.id
WHERE p.username LIKE ?
GROUP BY p.id, p.username, p.region, p.created_at
ORDER BY total_points DESC, p.username ASC
LIMIT 10`,
[`%${q}%`]
);
if (matches.length === 0) {
return res.json({ data: [] });
}
const rankRows = await fetchRanksForPlayerIds(matches.map((m) => m.id));
const hydrated = hydratePlayersFromRows(matches, rankRows, 0).map((row) => {
const next = { ...row };
delete next.position;
return next;
});
return res.json({ data: hydrated });
} catch (error) {
console.error("GET /api/search failed:", error?.message || error);
return sendError(res, 500, "SEARCH_FAILED", "Failed to search players");
}
});
module.exports = router;