diff --git a/public/index.html b/public/index.html
index c6b4185..1219d98 100644
--- a/public/index.html
+++ b/public/index.html
@@ -274,6 +274,14 @@
+
+
+ π Tableau des scores
+
+
+
π₯ Joueurs actifs
diff --git a/public/src/api.js b/public/src/api.js
index 273999e..019c2d8 100644
--- a/public/src/api.js
+++ b/public/src/api.js
@@ -157,3 +157,9 @@ export async function apiCaptureCell(seed, x, y) {
body: JSON.stringify({ seed, x, y }),
});
}
+
+export async function apiFetchRoundHistory() {
+ const res = await fetch("/api/round-history");
+ if (!res.ok) throw new Error("round_history_fetch_failed");
+ return res.json();
+}
diff --git a/public/src/game.js b/public/src/game.js
index 2ead9b6..568afc3 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, apiFetchActivePlayerNames, apiFetchMilitaryDeductions, apiMilitaryAttack, apiCaptureCell } from "./api.js";
+import { apiFetchConfig, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints, apiFetchActivePlayers, apiFetchActivePlayerNames, apiFetchMilitaryDeductions, apiMilitaryAttack, apiCaptureCell, apiFetchRoundHistory } from "./api.js";
import { computeTeamIncome, computeTeamElementBonus, computeTeamElementBonusDetailed, renderResourceTable, renderElementBonusTable, setEconSort, getEconSort, setElemSort, getElemSort, computeTeamMilitaryDetailed, renderMilitaryTable, setMilSort, getMilSort, computeTeamIncomeByPlanet, renderResourceByPlanetTable, setPlanetSort, getPlanetSort } from "./economy.js";
// ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -265,6 +265,7 @@ const playerListTableWrapEl = document.getElementById("playerListTableWrap");
const playerListSearchEl = document.getElementById("playerListSearch");
const playerListSortBtnEl = document.getElementById("playerListSortBtn");
const mapAnimEl = document.getElementById("mapAnim");
+const roundHistoryTableBodyEl = document.getElementById("roundHistoryTableBody");
// ββ Cell helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export function cellKey(x, y) { return `${x},${y}`; }
@@ -369,6 +370,55 @@ export async function loadVictoryPoints() {
} catch { /* ignore */ }
}
+export async function loadRoundHistory() {
+ if (!roundHistoryTableBodyEl) return;
+ try {
+ const history = await apiFetchRoundHistory();
+ renderRoundHistoryTable(history);
+ } catch { /* ignore */ }
+}
+
+function renderRoundHistoryTable(history) {
+ if (!roundHistoryTableBodyEl) return;
+ if (!history || history.length === 0) {
+ roundHistoryTableBodyEl.innerHTML = `Aucune manche terminΓ©e.
`;
+ return;
+ }
+ const rows = history.map(entry => {
+ const blueScore = entry.blueScore;
+ const redScore = entry.redScore;
+ const blueWins = blueScore >= redScore;
+ const date = new Date(entry.endedAt);
+ const label = date.toLocaleString("fr-FR", {
+ day: "2-digit", month: "2-digit", year: "2-digit",
+ hour: "2-digit", minute: "2-digit",
+ });
+ const fmt = (v) => v >= 1e6
+ ? (v / 1e6).toFixed(1) + " M"
+ : v >= 1e3
+ ? (v / 1e3).toFixed(1) + " k"
+ : v.toFixed(0);
+ const blueClass = blueWins ? "roundHistoryCell--winner-blue" : "";
+ const redClass = !blueWins ? "roundHistoryCell--winner-red" : "";
+ return `
+ | ${label} |
+ ${fmt(blueScore)} |
+ ${fmt(redScore)} |
+
`;
+ }).join("");
+ roundHistoryTableBodyEl.innerHTML = `
+
+
+
+ |
+ RΓ©sistance |
+ Premier Ordre |
+
+
+ ${rows}
+
`;
+}
+
export async function fetchAndApplyActivePlayers() {
try {
const { blue, red } = await apiFetchActivePlayers();
diff --git a/public/src/main.js b/public/src/main.js
index 64849b8..324cbe2 100644
--- a/public/src/main.js
+++ b/public/src/main.js
@@ -10,6 +10,7 @@ import {
updateEconomyDisplay,
loadEconScores,
loadVictoryPoints,
+ loadRoundHistory,
loadDbInfo,
loadElementBonus,
loadMilitaryDeductions,
@@ -98,7 +99,10 @@ function startFallbackPolling() {
fallbackPollingEnabled = true;
scheduleConfigPoll();
scheduleScorePoll();
- victoryPollTimer = setInterval(loadVictoryPoints, 30_000);
+ victoryPollTimer = setInterval(async () => {
+ await loadVictoryPoints();
+ await loadRoundHistory();
+ }, 30_000);
gridPollTimer = setInterval(refreshGridDisplay, 1_000);
}
@@ -113,6 +117,7 @@ async function syncFromServer() {
try {
await refreshFromServer();
await loadVictoryPoints();
+ await loadRoundHistory();
} finally {
syncInFlight = false;
}
@@ -210,6 +215,7 @@ async function boot() {
await fetchAndApplyActivePlayers();
await loadEconScores();
await loadVictoryPoints();
+ await loadRoundHistory();
await loadDbInfo();
await loadElementBonus();
await loadMilitaryDeductions();
diff --git a/public/style.css b/public/style.css
index 8bb8376..c010e2e 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1397,4 +1397,75 @@ canvas {
z-index: 20;
user-select: none;
white-space: nowrap;
+}
+
+/* ββ Round history table ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+
+.roundHistoryWrap {
+ padding: 6px 0 4px;
+}
+
+.roundHistoryTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ table-layout: fixed;
+}
+
+.roundHistoryTable thead th {
+ padding: 4px 8px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ border-bottom: 1px solid rgba(255,255,255,0.1);
+ text-align: center;
+}
+
+.roundHistoryTable thead th:first-child {
+ text-align: left;
+ width: 36%;
+}
+
+.roundHistoryTh--blue { color: rgba(90, 200, 255, 0.9); }
+.roundHistoryTh--red { color: rgba(220, 75, 85, 0.9); }
+
+.roundHistoryTable tbody tr {
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+}
+
+.roundHistoryTable td {
+ padding: 5px 8px;
+ text-align: center;
+ color: rgba(255,255,255,0.7);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.roundHistoryTable td:first-child {
+ text-align: left;
+ font-size: 10px;
+ color: rgba(255,255,255,0.4);
+}
+
+.roundHistoryCell--winner-blue {
+ background: rgba(90, 200, 255, 0.18);
+ color: rgba(90, 200, 255, 1);
+ font-weight: 700;
+ border-radius: 3px;
+}
+
+.roundHistoryCell--winner-red {
+ background: rgba(220, 75, 85, 0.18);
+ color: rgba(220, 75, 85, 1);
+ font-weight: 700;
+ border-radius: 3px;
+}
+
+.roundHistoryEmpty {
+ padding: 10px 8px;
+ font-size: 12px;
+ color: rgba(255,255,255,0.35);
+ font-style: italic;
}
\ No newline at end of file
diff --git a/server/db/gameDb.js b/server/db/gameDb.js
index 66ea245..dbe0aea 100644
--- a/server/db/gameDb.js
+++ b/server/db/gameDb.js
@@ -134,6 +134,17 @@ export async function initGameSchema() {
await pool.query(`
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS captured_by TEXT;
`);
+ // ββ Round history (one row per completed seed epoch) βββββββββββββββββββββ
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS round_history (
+ id SERIAL PRIMARY KEY,
+ world_seed TEXT NOT NULL,
+ ended_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ blue_score DOUBLE PRECISION NOT NULL DEFAULT 0,
+ red_score DOUBLE PRECISION NOT NULL DEFAULT 0
+ );
+ CREATE INDEX IF NOT EXISTS idx_round_history_ended_at ON round_history (ended_at DESC);
+ `);
}
// ββ World-seed epoch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -164,6 +175,11 @@ export async function ensureSeedEpoch() {
);
console.log(`[world] VP awarded to ${winner} for seed ${expiredSeed} (blue=${scores.blue.toFixed(3)}, red=${scores.red.toFixed(3)})`);
}
+ // Record round result in history regardless of whether scores are non-zero
+ await pool.query(
+ `INSERT INTO round_history (world_seed, blue_score, red_score) VALUES ($1, $2, $3)`,
+ [expiredSeed, scores.blue, scores.red]
+ );
} catch (e) {
console.error("[world] VP award error:", e);
}
@@ -347,10 +363,27 @@ export async function getDbCreatedAt() {
return rows[0]?.created_at ?? null;
}
+// ββ Round history βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export async function getRoundHistory(limit = 10) {
+ const { rows } = await pool.query(
+ `SELECT world_seed, ended_at, blue_score, red_score
+ FROM round_history
+ ORDER BY ended_at DESC
+ LIMIT $1`,
+ [limit]
+ );
+ return rows.map(r => ({
+ worldSeed: r.world_seed,
+ endedAt: r.ended_at,
+ blueScore: Number(r.blue_score),
+ redScore: Number(r.red_score),
+ }));
+}
+
// ββ Victory points ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-export async function getVictoryPoints() {
- const { rows } = await pool.query(
+export async function getVictoryPoints() { const { rows } = await pool.query(
`SELECT team, COUNT(*) AS cnt FROM victory_points GROUP BY team`
);
const result = { blue: 0, red: 0 };
diff --git a/server/routes/game.js b/server/routes/game.js
index 2ae6108..b15dcea 100644
--- a/server/routes/game.js
+++ b/server/routes/game.js
@@ -32,6 +32,7 @@ import {
checkTeamVisibility,
insertTeamVisibility,
getTeamVisibleCells,
+ getRoundHistory,
} from "../db/gameDb.js";
import {
nextResetUtc,
@@ -553,4 +554,15 @@ router.get("/cell/attacks", async (req, res) => {
}
});
+// GET /api/round-history
+router.get("/round-history", async (_req, res) => {
+ try {
+ const history = await getRoundHistory(10);
+ res.json(history);
+ } catch (e) {
+ console.error(e);
+ res.status(500).json({ error: "database_error" });
+ }
+});
+
export default router;
\ No newline at end of file