${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);