Add battle death stats and HUD updates
This commit is contained in:
+14
-1
@@ -11,8 +11,10 @@ const DEFAULT_CONFIG = {
|
||||
MONGODB_PASS: "",
|
||||
MONGODB_URI: "",
|
||||
MONGODB_VISITOR_COLLECTION: "visitors",
|
||||
MONGODB_DAILY_DEATH_COLLECTION: "daily_death_stats",
|
||||
MONGODB_MAX_POOL_SIZE: 10,
|
||||
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
|
||||
DEATH_STATS_TIME_ZONE: "Asia/Seoul",
|
||||
COOKIE_SECURE: false,
|
||||
};
|
||||
|
||||
@@ -47,7 +49,7 @@ export function getMongoUri() {
|
||||
}
|
||||
|
||||
if (!appConfig.MONGODB_HOST) {
|
||||
throw new Error("MongoDB configuration is required for visitor tracking.");
|
||||
throw new Error("MongoDB configuration is required for arena tracking.");
|
||||
}
|
||||
|
||||
const host = appConfig.MONGODB_HOST;
|
||||
@@ -75,6 +77,11 @@ function normalizeConfig(rawConfig) {
|
||||
mongodb.visitorCollection,
|
||||
DEFAULT_CONFIG.MONGODB_VISITOR_COLLECTION,
|
||||
),
|
||||
MONGODB_DAILY_DEATH_COLLECTION: stringValue(
|
||||
rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
|
||||
mongodb.dailyDeathCollection,
|
||||
DEFAULT_CONFIG.MONGODB_DAILY_DEATH_COLLECTION,
|
||||
),
|
||||
MONGODB_MAX_POOL_SIZE: numberValue(
|
||||
rawConfig.MONGODB_MAX_POOL_SIZE,
|
||||
mongodb.maxPoolSize,
|
||||
@@ -85,6 +92,12 @@ function normalizeConfig(rawConfig) {
|
||||
mongodb.serverSelectionTimeoutMs,
|
||||
DEFAULT_CONFIG.MONGODB_SERVER_SELECTION_TIMEOUT_MS,
|
||||
),
|
||||
DEATH_STATS_TIME_ZONE: stringValue(
|
||||
rawConfig.DEATH_STATS_TIME_ZONE,
|
||||
rawConfig.TIME_ZONE,
|
||||
mongodb.deathStatsTimeZone,
|
||||
DEFAULT_CONFIG.DEATH_STATS_TIME_ZONE,
|
||||
),
|
||||
COOKIE_SECURE: booleanValue(rawConfig.COOKIE_SECURE, server.cookieSecure, DEFAULT_CONFIG.COOKIE_SECURE),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { getConfig } from "./config.js";
|
||||
import { getDb } from "./db.js";
|
||||
|
||||
const DEFAULT_DAILY_COLLECTION_NAME = "daily_death_stats";
|
||||
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
||||
|
||||
let dailyIndexesReady;
|
||||
|
||||
export async function deathStatsRoutes(fastify) {
|
||||
fastify.get("/today", async () => {
|
||||
return getTodayDeathStats();
|
||||
});
|
||||
|
||||
fastify.post("/today", async (request) => {
|
||||
const payload = parseJsonBody(request.body);
|
||||
const deathsBySpecies = normalizeDeathCounts(payload.deathsBySpecies);
|
||||
const totalDeaths = totalCount(deathsBySpecies);
|
||||
const now = new Date();
|
||||
const date = dayKey(now);
|
||||
const collection = await getDailyCollection();
|
||||
|
||||
await ensureDailyDeathStatsIndex(collection);
|
||||
|
||||
if (totalDeaths === 0) {
|
||||
const today = await collection.findOne({ _id: date });
|
||||
|
||||
return {
|
||||
saved: false,
|
||||
today: formatDailyStats(today, date),
|
||||
};
|
||||
}
|
||||
|
||||
await collection.updateOne(
|
||||
{ _id: date },
|
||||
{
|
||||
$setOnInsert: {
|
||||
_id: date,
|
||||
date,
|
||||
createdAt: now,
|
||||
},
|
||||
$set: {
|
||||
updatedAt: now,
|
||||
},
|
||||
$inc: {
|
||||
battles: 1,
|
||||
totalDeaths,
|
||||
...speciesIncrements(deathsBySpecies),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
const today = await collection.findOne({ _id: date });
|
||||
|
||||
return {
|
||||
saved: true,
|
||||
today: formatDailyStats(today, date),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getTodayDeathStats() {
|
||||
const collection = await getDailyCollection();
|
||||
await ensureDailyDeathStatsIndex(collection);
|
||||
|
||||
const date = dayKey(new Date());
|
||||
const today = await collection.findOne({ _id: date });
|
||||
|
||||
return formatDailyStats(today, date);
|
||||
}
|
||||
|
||||
async function getDailyCollection() {
|
||||
const db = await getDb();
|
||||
return db.collection(
|
||||
getConfig().MONGODB_DAILY_DEATH_COLLECTION || DEFAULT_DAILY_COLLECTION_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureDailyDeathStatsIndex(collection) {
|
||||
if (!dailyIndexesReady) {
|
||||
dailyIndexesReady = collection.createIndex({ updatedAt: -1 });
|
||||
}
|
||||
|
||||
return dailyIndexesReady;
|
||||
}
|
||||
|
||||
function parseJsonBody(body) {
|
||||
if (!body) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof body === "object") {
|
||||
return body;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeathCounts(value = {}) {
|
||||
return SPECIES_KEYS.reduce((counts, species) => {
|
||||
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function speciesIncrements(deathsBySpecies) {
|
||||
return SPECIES_KEYS.reduce((increments, species) => {
|
||||
increments[`deathsBySpecies.${species}`] = deathsBySpecies[species] ?? 0;
|
||||
return increments;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function formatDailyStats(document, date) {
|
||||
const deathsBySpecies = normalizeDeathCounts(document?.deathsBySpecies);
|
||||
|
||||
return {
|
||||
date,
|
||||
battles: Math.max(0, Math.round(Number(document?.battles) || 0)),
|
||||
deathsBySpecies,
|
||||
totalDeaths: Math.max(
|
||||
totalCount(deathsBySpecies),
|
||||
Math.round(Number(document?.totalDeaths) || 0),
|
||||
),
|
||||
updatedAt: document?.updatedAt?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function totalCount(deathsBySpecies) {
|
||||
return SPECIES_KEYS.reduce((sum, species) => sum + (deathsBySpecies[species] ?? 0), 0);
|
||||
}
|
||||
|
||||
function dayKey(date) {
|
||||
const timeZone = getConfig().DEATH_STATS_TIME_ZONE || "Asia/Seoul";
|
||||
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
})
|
||||
.formatToParts(date)
|
||||
.reduce((result, part) => {
|
||||
result[part.type] = part.value;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
+40
-20
@@ -1,10 +1,10 @@
|
||||
import fastifyMiddie from "@fastify/middie";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import Fastify from "fastify";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getConfig } from "./config.js";
|
||||
import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js";
|
||||
import { deathStatsRoutes } from "./deathStats.js";
|
||||
import { visitorRoutes } from "./visitors.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -13,6 +13,7 @@ const isProduction = process.env.NODE_ENV === "production" || process.argv.inclu
|
||||
const appConfig = getConfig();
|
||||
const port = appConfig.SERVER_PORT;
|
||||
const host = appConfig.SERVER_HOST;
|
||||
let viteDevServer;
|
||||
|
||||
const app = Fastify({
|
||||
bodyLimit: 16 * 1024,
|
||||
@@ -29,7 +30,22 @@ app.get("/api/health", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
if (!isProduction) {
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
viteDevServer = await createViteServer({
|
||||
root,
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
server: app.server,
|
||||
},
|
||||
},
|
||||
appType: "spa",
|
||||
});
|
||||
}
|
||||
|
||||
await app.register(visitorRoutes, { prefix: "/api/visitors" });
|
||||
await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
|
||||
|
||||
if (isProduction) {
|
||||
await app.register(fastifyStatic, {
|
||||
@@ -48,27 +64,31 @@ if (isProduction) {
|
||||
reply.sendFile("index.html");
|
||||
});
|
||||
} else {
|
||||
await app.register(fastifyMiddie);
|
||||
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const vite = await createViteServer({
|
||||
root,
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
server: app.server,
|
||||
},
|
||||
},
|
||||
appType: "spa",
|
||||
});
|
||||
|
||||
app.use((request, response, next) => {
|
||||
if (request.url?.startsWith("/api/")) {
|
||||
next();
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith("/api/")) {
|
||||
reply.code(404).send({ error: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
vite.middlewares(request, response, next);
|
||||
reply.hijack();
|
||||
viteDevServer.middlewares(request.raw, reply.raw, (error) => {
|
||||
if (error) {
|
||||
viteDevServer.ssrFixStacktrace(error);
|
||||
console.error(error);
|
||||
|
||||
if (!reply.raw.headersSent) {
|
||||
reply.raw.statusCode = 500;
|
||||
reply.raw.end(error.stack);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reply.raw.headersSent && !reply.raw.writableEnded) {
|
||||
reply.raw.statusCode = 404;
|
||||
reply.raw.end("Not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,7 +99,7 @@ app.setErrorHandler((error, request, reply) => {
|
||||
console.error(error);
|
||||
reply.code(status).send({
|
||||
error: isMissingMongoConfig ? "mongodb_not_configured" : "internal_server_error",
|
||||
message: isProduction ? "Visitor tracking is unavailable." : error.message,
|
||||
message: isProduction ? "Arena tracking is unavailable." : error.message,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user