diff --git a/public/index.html b/public/index.html index 56a2312..435ee1c 100644 --- a/public/index.html +++ b/public/index.html @@ -187,6 +187,7 @@
πŸͺ Statistiques PlanΓ©taires
Les stats sont vides sauf Γ  cliquer sur une tuile exploitable.
+
@@ -286,6 +287,11 @@ + + +
diff --git a/public/src/api.js b/public/src/api.js index b9e1615..4a5600d 100644 --- a/public/src/api.js +++ b/public/src/api.js @@ -108,6 +108,12 @@ export async function apiFetchActivePlayers() { return res.json(); } +export async function apiFetchActivePlayerNames() { + const res = await fetch("/api/active-players/names"); + if (!res.ok) throw new Error("active_player_names_fetch_failed"); + return res.json(); +} + export async function apiFetchMilitaryDeductions() { const res = await fetch("/api/military-deductions"); if (!res.ok) throw new Error("military_deductions_fetch_failed"); diff --git a/public/src/game.js b/public/src/game.js index d481df3..cf2b317 100644 --- a/public/src/game.js +++ b/public/src/game.js @@ -1,6 +1,6 @@ import { fnv1a32, hash2u32, mulberry32 } from "./rng.js"; import { formatPlanet, generatePlanet } from "./planetGeneration.js"; -import { apiFetchConfig, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers, apiFetchMilitaryDeductions, apiMilitaryAttack, apiCaptureCell } from "./api.js"; +import { apiFetchConfig, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers, apiFetchActivePlayerNames, apiFetchMilitaryDeductions, apiMilitaryAttack, apiCaptureCell } from "./api.js"; import { computeTeamIncome, computeTeamElementBonus, computeTeamElementBonusDetailed, renderResourceTable, renderElementBonusTable, setEconSort, getEconSort, setElemSort, getElemSort, computeTeamMilitaryDetailed, renderMilitaryTable, setMilSort, getMilSort } from "./economy.js"; // ── Constants ───────────────────────────────────────────────────────────────── @@ -234,6 +234,9 @@ const captureModalBodyEl = document.getElementById("captureModalBody"); const captureModalYesEl = document.getElementById("captureModalYes"); const captureModalNoEl = document.getElementById("captureModalNo"); const teamQuotaEl = document.getElementById("teamActionsRemaining"); +const captorInfoEl = document.getElementById("captorInfo"); +const playerListPopupEl = document.getElementById("playerListPopup"); +const playerListContentEl = document.getElementById("playerListContent"); // ── Cell helpers ────────────────────────────────────────────────────────────── export function cellKey(x, y) { return `${x},${y}`; } @@ -325,6 +328,64 @@ export async function fetchAndApplyActivePlayers() { } catch { /* ignore */ } } +// ── Player list popup (click on joueur count) ───────────────────────────────── + +function closePlayerListPopup() { + if (playerListPopupEl) playerListPopupEl.classList.add("hidden"); +} + +async function openPlayerListPopup(anchorEl, team) { + if (!playerListPopupEl || !playerListContentEl) return; + try { + const names = await apiFetchActivePlayerNames(); + const list = names[team] ?? []; + if (!list.length) { + playerListContentEl.innerHTML = `Aucun joueur actif`; + } else { + const teamClass = `playerListName--${team}`; + playerListContentEl.innerHTML = + list.map(u => `
${escHtml(u)}
`).join(""); + } + // Position popup below the anchor + const rect = anchorEl.getBoundingClientRect(); + const parentRect = anchorEl.closest(".infoColumn, aside")?.getBoundingClientRect() ?? { left: 0, top: 0 }; + playerListPopupEl.style.left = `${rect.left - parentRect.left}px`; + playerListPopupEl.style.top = `${rect.bottom - parentRect.top + 4}px`; + playerListPopupEl.classList.remove("hidden"); + playerListPopupEl.dataset.team = team; + } catch { /* ignore */ } +} + +if (activeCountBlueEl) { + activeCountBlueEl.style.cursor = "pointer"; + activeCountBlueEl.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (playerListPopupEl && !playerListPopupEl.classList.contains("hidden") && playerListPopupEl.dataset.team === "blue") { + closePlayerListPopup(); + } else { + openPlayerListPopup(activeCountBlueEl, "blue"); + } + }); +} + +if (activeCountRedEl) { + activeCountRedEl.style.cursor = "pointer"; + activeCountRedEl.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (playerListPopupEl && !playerListPopupEl.classList.contains("hidden") && playerListPopupEl.dataset.team === "red") { + closePlayerListPopup(); + } else { + openPlayerListPopup(activeCountRedEl, "red"); + } + }); +} + +document.addEventListener("click", (ev) => { + if (playerListPopupEl && !playerListPopupEl.contains(ev.target)) { + closePlayerListPopup(); + } +}); + // ── Element bonus ───────────────────────────────────────────────────────────── let elemBonusBlue = 0; @@ -534,6 +595,7 @@ export async function fetchGridForSeed(seed, depth = 0) { controlledBy: row.discovered_by ?? row.discoveredBy ?? null, hasPlanet: Boolean(row.has_planet), planet: row.planet_json ?? null, + capturedBy: row.captured_by ?? row.capturedBy ?? null, }); } } @@ -696,6 +758,20 @@ function refreshCursorFromLast() { // ── Selection display ───────────────────────────────────────────────────────── +/** Escapes a string for safe HTML insertion. */ +function escHtml(str) { + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** Shows or hides the captor info banner below planet stats. */ +function showCaptorInfo(capturedBy, team) { + if (!captorInfoEl) return; + if (!capturedBy) { captorInfoEl.className = "captorInfo hidden"; return; } + const teamClass = team === "blue" ? "captorInfo--blue" : team === "red" ? "captorInfo--red" : ""; + captorInfoEl.className = `captorInfo ${teamClass}`; + captorInfoEl.innerHTML = `CapturΓ©e par ${escHtml(capturedBy)}`; +} + function applyRevealPayload(cell) { const _revealKey = cellKey(cell.x, cell.y); markTileReveal(_revealKey); @@ -703,16 +779,19 @@ function applyRevealPayload(cell) { controlledBy: cell.discoveredBy ?? null, hasPlanet: Boolean(cell.hasPlanet), planet: cell.planet ?? null, + capturedBy: cell.capturedBy ?? null, }); details.classList.remove("details--hidden"); if (!cell.exploitable) { hint.textContent = `(${cell.x},${cell.y}) Inexploitable`; details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : Inexploitable`; + showCaptorInfo(null, null); return; } if (!cell.hasPlanet) { hint.textContent = `(${cell.x},${cell.y}) Vide`; details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : Vide`; + showCaptorInfo(null, null); return; } const controlStatus = cell.discoveredBy === null @@ -722,6 +801,7 @@ function applyRevealPayload(cell) { : `ContrΓ΄lΓ©e par l'adversaire`; hint.textContent = `(${cell.x},${cell.y}) PlanΓ¨te prΓ©sente`; details.textContent = `Tuile (${cell.x},${cell.y})\n\nStatus : ${controlStatus}\n\n${formatPlanet(cell.planet)}`; + showCaptorInfo(cell.capturedBy ?? null, cell.discoveredBy); } function showLocalSelection(x, y) { @@ -732,11 +812,13 @@ function showLocalSelection(x, y) { if (!isExploitable(x, y)) { hint.textContent = `(${x},${y}) Inexploitable`; details.textContent = `Tuile (${x},${y})\n\nStatus : Inexploitable`; + showCaptorInfo(null, null); return; } if (!meta.hasPlanet) { hint.textContent = `(${x},${y}) Vide`; details.textContent = `Tuile (${x},${y})\n\nStatus : Vide`; + showCaptorInfo(null, null); return; } let planet = meta.planet; @@ -751,6 +833,7 @@ function showLocalSelection(x, y) { : `ContrΓ΄lΓ©e par l'adversaire`; hint.textContent = `(${x},${y}) PlanΓ¨te prΓ©sente`; details.textContent = `Tuile (${x},${y})\n\nStatus : ${controlStatus}\n\n${formatPlanet(planet)}`; + showCaptorInfo(meta.capturedBy ?? null, meta.controlledBy); } // ── Military attack modal ──────────────────────────────────────────────────── @@ -896,7 +979,7 @@ async function onCanvasClick(ev) { if (!res.ok) { hint.textContent = "Erreur lors de la capture."; return; } const data = await res.json(); markTileReveal(key); - cells.set(key, { ...meta, controlledBy: currentTeam }); + cells.set(key, { ...meta, controlledBy: currentTeam, capturedBy: data.cell?.capturedBy ?? null }); if (data.teamActionsRemaining !== undefined && data.teamActionsRemaining !== null) { teamActionsRemaining = data.teamActionsRemaining; updateTeamQuotaDisplay(); diff --git a/public/style.css b/public/style.css index 2311e89..7f2e117 100644 --- a/public/style.css +++ b/public/style.css @@ -1231,4 +1231,68 @@ canvas { .scoreStatVal { font-size: 16px; } +} + +/* ── Captor info banner ────────────────────────────────────────────────────── */ + +.captorInfo { + padding: 6px 14px 10px; + font-family: "Courier New", Courier, monospace; + font-size: 11px; + opacity: 0.85; +} + +.captorInfo.hidden { + display: none; +} + +.captorInfo--blue { + color: rgba(90, 200, 255, 0.9); +} + +.captorInfo--red { + color: rgba(220, 75, 85, 0.9); +} + +/* ── Player list popup ─────────────────────────────────────────────────────── */ + +.playerListPopup { + position: absolute; + z-index: 30; + min-width: 120px; + max-width: 200px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.13); + background: rgba(12, 18, 38, 0.97); + backdrop-filter: blur(8px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + padding: 8px 10px; + font-size: 11px; + font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; + pointer-events: auto; +} + +.playerListPopup.hidden { + display: none; +} + +.playerListName--blue { + color: rgba(90, 200, 255, 0.9); + padding: 2px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.playerListName--red { + color: rgba(220, 75, 85, 0.9); + padding: 2px 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.playerListEmpty { + color: rgba(233, 238, 246, 0.4); + font-style: italic; } \ No newline at end of file diff --git a/server/db/gameDb.js b/server/db/gameDb.js index f54b92d..6b5220f 100644 --- a/server/db/gameDb.js +++ b/server/db/gameDb.js @@ -140,6 +140,10 @@ export async function initGameSchema() { ALTER TABLE grid_cells ADD CONSTRAINT grid_cells_discovered_by_check CHECK (discovered_by IS NULL OR discovered_by IN ('blue', 'red')); `); + // ── Store the username of whoever last captured a tile ──────────────────── + await pool.query(` + ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS captured_by TEXT; + `); } // ── World-seed epoch ────────────────────────────────────────────────────────── @@ -203,7 +207,7 @@ export async function ensureSeedEpoch() { export async function getGridCells(worldSeed) { const { rows } = await pool.query( - `SELECT x, y, exploitable, has_planet, planet_json, discovered_by + `SELECT x, y, exploitable, has_planet, planet_json, discovered_by, captured_by FROM grid_cells WHERE world_seed = $1`, [worldSeed] ); @@ -246,7 +250,7 @@ export async function insertTeamVisibility(worldSeed, team, x, y) { /** Returns all grid cells that are visible to a given team. */ export async function getTeamVisibleCells(worldSeed, team) { const { rows } = await pool.query( - `SELECT gc.x, gc.y, gc.exploitable, gc.has_planet, gc.planet_json, gc.discovered_by + `SELECT gc.x, gc.y, gc.exploitable, gc.has_planet, gc.planet_json, gc.discovered_by, gc.captured_by FROM grid_cells gc JOIN team_cell_visibility tcv ON tcv.world_seed = gc.world_seed AND tcv.x = gc.x AND tcv.y = gc.y @@ -258,7 +262,7 @@ export async function getTeamVisibleCells(worldSeed, team) { export async function getExistingCell(seed, x, y) { const { rows } = await pool.query( - `SELECT x, y, exploitable, has_planet, planet_json, discovered_by + `SELECT x, y, exploitable, has_planet, planet_json, discovered_by, captured_by FROM grid_cells WHERE world_seed = $1 AND x = $2 AND y = $3`, [seed, x, y] ); @@ -431,13 +435,26 @@ export async function getCellAttackCount(worldSeed, x, y) { return rows[0]?.cnt ?? 0; } -export async function setTileOwner(worldSeed, x, y, team) { +export async function setTileOwner(worldSeed, x, y, team, capturedBy = null) { await pool.query( - `UPDATE grid_cells SET discovered_by = $1 WHERE world_seed = $2 AND x = $3 AND y = $4`, - [team, worldSeed, x, y] + `UPDATE grid_cells SET discovered_by = $1, captured_by = $2 WHERE world_seed = $3 AND x = $4 AND y = $5`, + [team, capturedBy, worldSeed, x, y] ); } +/** Returns per-team lists of user IDs who have been active this seed epoch. */ +export async function getActivePlayerIds(worldSeed) { + const { rows } = await pool.query( + `SELECT team, user_id FROM user_cooldowns WHERE world_seed = $1`, + [worldSeed] + ); + const result = { blue: [], red: [] }; + for (const row of rows) { + if (result[row.team]) result[row.team].push(row.user_id); + } + return result; +} + // ── Team action quota (daily, independent of world seed) ───────────────────── export async function getTeamActionsRow(team) { diff --git a/server/db/usersDb.js b/server/db/usersDb.js index 9d3d76f..bd89dc3 100644 --- a/server/db/usersDb.js +++ b/server/db/usersDb.js @@ -75,6 +75,16 @@ export async function getTeamPlayerCounts() { return result; } +/** Returns username and team for each of the given user IDs. */ +export async function getUsersByIds(ids) { + if (!ids.length) return []; + const { rows } = await usersPool.query( + `SELECT id, username, team FROM users WHERE id = ANY($1::int[])`, + [ids] + ); + return rows; +} + // ── User action quota ───────────────────────────────────────────────────────── /** Returns the current quota row for a user, or null if it doesn't exist. */ diff --git a/server/helpers/cell.js b/server/helpers/cell.js index d7fa7b3..a63d30f 100644 --- a/server/helpers/cell.js +++ b/server/helpers/cell.js @@ -51,5 +51,6 @@ export function rowToCellPayload(row) { hasPlanet: row.has_planet, planet: row.planet_json ?? null, discoveredBy: row.discovered_by, + capturedBy: row.captured_by ?? null, }; } \ No newline at end of file diff --git a/server/routes/game.js b/server/routes/game.js index 33cbfe8..8123ec5 100644 --- a/server/routes/game.js +++ b/server/routes/game.js @@ -19,6 +19,7 @@ import { getDbCreatedAt, getVictoryPoints, getActivePlayerCounts, + getActivePlayerIds, getMilitaryDeductions, addMilitaryDeduction, recordCellAttack, @@ -37,6 +38,7 @@ import { getUserActionsRow, resetUserActions, decrementUserActions, + getUsersByIds, } from "../db/usersDb.js"; import { computeCell, rowToCellPayload } from "../helpers/cell.js"; import { computeTeamMilitaryPower } from "../helpers/economy.js"; @@ -252,7 +254,7 @@ router.post("/cell/capture", authMiddleware, async (req, res) => { } // Transfer ownership to capturing team - await setTileOwner(worldSeed, x, y, team); + await setTileOwner(worldSeed, x, y, team, req.user.username); const updatedCell = await getExistingCell(worldSeed, x, y); const updatedTeamRow = await getTeamActionsRow(team); @@ -391,6 +393,24 @@ router.get("/active-players", async (_req, res) => { } }); +// GET /api/active-players/names +router.get("/active-players/names", async (_req, res) => { + try { + const worldSeed = await ensureSeedEpoch(); + const playerIds = await getActivePlayerIds(worldSeed); + const allIds = [...playerIds.blue, ...playerIds.red]; + const users = await getUsersByIds(allIds); + const result = { blue: [], red: [] }; + for (const user of users) { + if (result[user.team]) result[user.team].push(user.username); + } + res.json(result); + } catch (e) { + console.error(e); + res.status(500).json({ error: "database_error" }); + } +}); + // GET /api/scores router.get("/scores", async (_req, res) => { try { @@ -449,7 +469,7 @@ router.post("/military/attack", authMiddleware, async (req, res) => { await addMilitaryDeduction(worldSeed, attackingTeam, COST_BILLIONS); // Transfer tile ownership to the attacking team - await setTileOwner(worldSeed, x, y, attackingTeam); + await setTileOwner(worldSeed, x, y, attackingTeam, req.user.username); // Record the attack event await recordCellAttack(worldSeed, x, y, attackingTeam);