Add battle death stats and HUD updates

This commit is contained in:
2026-05-22 17:01:01 +09:00
parent be80cb17b0
commit a41e9b6f26
19 changed files with 2408 additions and 264 deletions
+14 -1
View File
@@ -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),
};
}
+156
View File
@@ -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
View File
@@ -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,
});
});