Private
Public Access
1
0

feat(gameplay): Adding % bonus for planet type

This commit is contained in:
gauvainboiche
2026-03-30 15:43:43 +02:00
parent c0f66d8cc0
commit 3b229755f8
10 changed files with 548 additions and 81 deletions

View File

@@ -1,44 +1,54 @@
{ {
"clickCooldownSeconds": 2, "clickCooldownSeconds": 100,
"databaseWipeoutIntervalSeconds": 21600, "databaseWipeoutIntervalSeconds": 21600,
"debugModeForTeams": true, "debugModeForTeams": true,
"configReloadIntervalSeconds": 30, "configReloadIntervalSeconds": 30,
"elementWorth": {
"common": 1,
"petrol": 3,
"food": 2,
"medic": 5,
"science": 8,
"industry": 4,
"money": 6,
"goods": 3
},
"resourceWorth": { "resourceWorth": {
"common": { "common": {
"rock": 1, "rock": 100,
"wood": 1, "wood": 120,
"mineral": 2, "plant": 120,
"stones": 2, "grain": 150,
"liquid": 1, "liquid": 180,
"oil": 4, "fish": 200,
"gas": 3, "stones": 220,
"grain": 1, "animals": 300,
"livestock": 2, "livestock": 350,
"fish": 1, "acid": 400,
"plant": 1, "mineral": 450,
"goods": 4, "gas": 800,
"animals": 2, "oil": 1000,
"science": 6, "goods": 1200,
"factory": 5, "factory": 1500,
"acid": 1 "science": 2000
}, },
"rare": { "rare": {
"rock": 3, "rock": 300,
"wood": 3, "wood": 400,
"mineral": 4, "plant": 500,
"stones": 5, "grain": 450,
"liquid": 2, "liquid": 600,
"oil": 7, "fish": 900,
"gas": 6, "stones": 1200,
"grain": 3, "animals": 1500,
"livestock": 3, "livestock": 1400,
"fish": 5, "acid": 1800,
"plant": 3, "mineral": 2500,
"goods": 8, "gas": 3000,
"animals": 5, "oil": 3500,
"science": 15, "goods": 4000,
"factory": 12, "factory": 4500,
"acid": 3 "science": 5000
} }
} }
} }

View File

@@ -89,17 +89,35 @@
<!-- Team score display --> <!-- Team score display -->
<div class="scoreBoard" id="scoreBoard"> <div class="scoreBoard" id="scoreBoard">
<span class="scoreTeam scoreTeam--blue"> <img src="./graphism/logo_resistance.svg" alt="Resistance" class="team-logo" />
<img src="./graphism/logo_resistance.svg" alt="Resistance" class="team-logo" /> <div class="scoreTeam scoreTeam--blue">
<span class="scoreTeamName">Resistance</span> <span class="scoreTeamName">Résistance</span>
<span class="scoreValue" id="scoreBlue">0</span> <div class="scoreStats">
</span> <div class="scoreStat">
<span class="scoreStatVal scoreValue" id="scoreBlue">0</span>
<span class="scoreStatLabel">planètes</span>
</div>
<div class="scoreStat">
<span class="scoreStatVal scoreVP" id="vpBlue">0</span>
<span class="scoreStatLabel">pts victoire</span>
</div>
</div>
</div>
<span class="scoreSep"></span> <span class="scoreSep"></span>
<span class="scoreTeam scoreTeam--red"> <div class="scoreTeam scoreTeam--red">
<span class="scoreValue" id="scoreRed">0</span> <span class="scoreTeamName">Premier Ordre</span>
<span class="scoreTeamName">First Order</span> <div class="scoreStats">
<img src="./graphism/logo_first_order.svg" alt="First Order" class="team-logo" /> <div class="scoreStat">
</span> <span class="scoreStatVal scoreVP" id="vpRed">0</span>
<span class="scoreStatLabel">pts victoire</span>
</div>
<div class="scoreStat">
<span class="scoreStatVal scoreValue" id="scoreRed">0</span>
<span class="scoreStatLabel">planètes</span>
</div>
</div>
</div>
<img src="./graphism/logo_first_order.svg" alt="First Order" class="team-logo" />
</div> </div>
<!-- Info rows --> <!-- Info rows -->
@@ -134,6 +152,10 @@
<span class="infoKey muted">Prochaine graine dans</span> <span class="infoKey muted">Prochaine graine dans</span>
<code class="infoVal" id="refreshCountdown">--:--:--</code> <code class="infoVal" id="refreshCountdown">--:--:--</code>
</div> </div>
<div class="infoRow">
<span class="infoKey muted">Base de données depuis</span>
<code class="infoVal" id="dbCreatedAt"></code>
</div>
</div> </div>
<!-- Planet stats (collapsible) --> <!-- Planet stats (collapsible) -->
@@ -178,6 +200,28 @@
</div> </div>
</details> </details>
<!-- Element bonus section -->
<div class="elemBonusSection">
<div class="elemBonusSectionTitle">⚡ Bonus de recharge planétaire</div>
<div class="elemBonusRow">
<span class="elemBonusTeam elemBonusTeam--blue">
<span class="elemBonusLabel">Résistance</span>
<span class="elemBonusVal" id="elemBonusBlue">0.00</span>
<span class="elemBonusUnit">%</span>
</span>
<span class="elemBonusSep"></span>
<span class="elemBonusTeam elemBonusTeam--red">
<span class="elemBonusVal" id="elemBonusRed">0.00</span>
<span class="elemBonusUnit">%</span>
<span class="elemBonusLabel">Premier Ordre</span>
</span>
</div>
<div class="elemBonusDetail">
<span class="elemBonusDetailLabel">Recharge effective (votre équipe) :</span>
<span class="elemBonusDetailVal" id="effectiveCooldown"></span>
</div>
</div>
<!-- Admin / options section --> <!-- Admin / options section -->
<div class="infoSection infoSection--options"> <div class="infoSection infoSection--options">
<details class="optionsDetails"> <details class="optionsDetails">

View File

@@ -65,3 +65,31 @@ export async function apiTickEconScores(seed, blue, red) {
if (!res.ok) throw new Error("econ_tick_failed"); if (!res.ok) throw new Error("econ_tick_failed");
return res.json(); return res.json();
} }
export async function apiFetchElementBonus() {
const res = await fetch("/api/element-bonus");
if (!res.ok) throw new Error("element_bonus_fetch_failed");
return res.json();
}
export async function apiTickElementBonus(seed, blue, red) {
const res = await fetch("/api/element-bonus/tick", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed, blue, red }),
});
if (!res.ok) throw new Error("element_bonus_tick_failed");
return res.json();
}
export async function apiFetchDbInfo() {
const res = await fetch("/api/db-info");
if (!res.ok) throw new Error("db_info_fetch_failed");
return res.json();
}
export async function apiFetchVictoryPoints() {
const res = await fetch("/api/victory-points");
if (!res.ok) throw new Error("vp_fetch_failed");
return res.json();
}

View File

@@ -1,4 +1,4 @@
import { resources } from "./planetEconomy.js"; import { resources, elements } from "./planetEconomy.js";
// ── Sort state ──────────────────────────────────────────────────────────────── // ── Sort state ────────────────────────────────────────────────────────────────
@@ -64,6 +64,46 @@ export function computeTeamIncome(team, cells, resourceWorth) {
return { total, byResource }; return { total, byResource };
} }
// ── Element bonus calculation ─────────────────────────────────────────────────
/**
* Reverse map: French element label → config key
* e.g. "Matières premières" → "common", "Hydrocarbures" → "petrol"
*/
const ELEMENT_LABEL_TO_KEY = Object.fromEntries(
Object.entries(elements).map(([key, label]) => [label, key])
);
/**
* Compute cumulative element bonus for a team based on their planets' production.
* planet.production stores French label strings as keys (values from elements const).
* bonus = sum_over_planets( sum_over_elements( elementShare% / 100 * elementWorth[key] ) )
*
* @param {string} team
* @param {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} cells
* @param {object} elementWorth - { common: 1, petrol: 3, ... }
* @returns {number} bonus value (use as %)
*/
export function computeTeamElementBonus(team, cells, elementWorth) {
let bonus = 0;
for (const [, meta] of cells) {
if (meta.discoveredBy !== team) continue;
if (!meta.hasPlanet || !meta.planet) continue;
const { production } = meta.planet;
if (!production) continue;
for (const [elementLabel, pct] of Object.entries(production)) {
// production keys are French labels; map back to config key
const elementKey = ELEMENT_LABEL_TO_KEY[elementLabel] ?? elementLabel;
const worth = elementWorth?.[elementKey] ?? 0;
if (worth === 0) continue;
bonus += (pct / 100) * worth;
}
}
return bonus;
}
export { elements };
// ── Resource table for the sidebar ─────────────────────────────────────────── // ── Resource table for the sidebar ───────────────────────────────────────────
/** /**

View File

@@ -1,7 +1,7 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js"; import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js"; import { formatPlanet, generatePlanet } from "./planetGeneration.js";
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores } from "./api.js"; import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores, apiFetchElementBonus, apiTickElementBonus, apiFetchDbInfo, apiFetchVictoryPoints } from "./api.js";
import { computeTeamIncome, renderResourceTable, setEconSort, getEconSort } from "./economy.js"; import { computeTeamIncome, computeTeamElementBonus, renderResourceTable, setEconSort, getEconSort } from "./economy.js";
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@@ -27,6 +27,7 @@ export const GAME_CONFIG = {
configReloadIntervalSeconds: 30, configReloadIntervalSeconds: 30,
worldSeed: "", worldSeed: "",
seedPeriodEndsAtUtc: "", seedPeriodEndsAtUtc: "",
elementWorth: {},
resourceWorth: { common: {}, rare: {} }, resourceWorth: { common: {}, rare: {} },
}; };
window.GAME_CONFIG = GAME_CONFIG; window.GAME_CONFIG = GAME_CONFIG;
@@ -149,6 +150,12 @@ const nextPeriodEl = document.getElementById("nextPeriodUtc");
const resetCountEl = document.getElementById("refreshCountdown"); const resetCountEl = document.getElementById("refreshCountdown");
const scoreBlueEl = document.getElementById("scoreBlue"); const scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed"); const scoreRedEl = document.getElementById("scoreRed");
const vpBlueEl = document.getElementById("vpBlue");
const vpRedEl = document.getElementById("vpRed");
const dbCreatedAtEl = document.getElementById("dbCreatedAt");
const elemBonusBlueEl = document.getElementById("elemBonusBlue");
const elemBonusRedEl = document.getElementById("elemBonusRed");
const effectiveCooldownEl = document.getElementById("effectiveCooldown");
const incomeBlueEl = document.getElementById("incomeBlue"); const incomeBlueEl = document.getElementById("incomeBlue");
const incomeRedEl = document.getElementById("incomeRed"); const incomeRedEl = document.getElementById("incomeRed");
const resourceTableEl = document.getElementById("resourceTableBody"); const resourceTableEl = document.getElementById("resourceTableBody");
@@ -188,6 +195,9 @@ export function applyConfigPayload(data) {
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30); GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? ""); GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? ""); GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
if (data.elementWorth && typeof data.elementWorth === "object") {
GAME_CONFIG.elementWorth = data.elementWorth;
}
if (data.resourceWorth && typeof data.resourceWorth === "object") { if (data.resourceWorth && typeof data.resourceWorth === "object") {
GAME_CONFIG.resourceWorth = data.resourceWorth; GAME_CONFIG.resourceWorth = data.resourceWorth;
} }
@@ -232,8 +242,77 @@ export function lockTeamSwitcher() {
export async function fetchAndApplyScores() { export async function fetchAndApplyScores() {
try { try {
const { blue, red } = await apiFetchScores(); const { blue, red } = await apiFetchScores();
scoreBlueEl.textContent = String(blue ?? 0); if (scoreBlueEl) scoreBlueEl.textContent = String(blue ?? 0);
scoreRedEl.textContent = String(red ?? 0); if (scoreRedEl) scoreRedEl.textContent = String(red ?? 0);
} catch { /* ignore */ }
}
export async function loadVictoryPoints() {
try {
const { blue, red } = await apiFetchVictoryPoints();
if (vpBlueEl) vpBlueEl.textContent = String(blue ?? 0);
if (vpRedEl) vpRedEl.textContent = String(red ?? 0);
} catch { /* ignore */ }
}
// ── Element bonus ─────────────────────────────────────────────────────────────
let elemBonusBlue = 0;
let elemBonusRed = 0;
function updateEffectiveCooldownDisplay() {
const base = GAME_CONFIG.clickCooldownSeconds;
const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
const effective = base / (1 + bonus / 100);
if (elemBonusBlueEl) elemBonusBlueEl.textContent = elemBonusBlue.toFixed(2);
if (elemBonusRedEl) elemBonusRedEl.textContent = elemBonusRed.toFixed(2);
if (effectiveCooldownEl) {
effectiveCooldownEl.textContent = effective < 1
? `${(effective * 1000).toFixed(0)}ms`
: `${effective.toFixed(2)}s`;
}
}
export async function loadElementBonus() {
try {
const { blue, red } = await apiFetchElementBonus();
elemBonusBlue = blue ?? 0;
elemBonusRed = red ?? 0;
updateEffectiveCooldownDisplay();
} catch { /* ignore */ }
}
export async function tickElementBonus() {
const worth = GAME_CONFIG.elementWorth;
const blueBonus = computeTeamElementBonus("blue", cells, worth);
const redBonus = computeTeamElementBonus("red", cells, worth);
try {
const result = await apiTickElementBonus(seedStr, blueBonus, redBonus);
elemBonusBlue = result.blue ?? blueBonus;
elemBonusRed = result.red ?? redBonus;
} catch {
elemBonusBlue = blueBonus;
elemBonusRed = redBonus;
}
updateEffectiveCooldownDisplay();
}
export function getEffectiveCooldown() {
const base = GAME_CONFIG.clickCooldownSeconds;
const bonus = currentTeam === "blue" ? elemBonusBlue : elemBonusRed;
return base / (1 + bonus / 100);
}
export async function loadDbInfo() {
try {
const { createdAt } = await apiFetchDbInfo();
if (dbCreatedAtEl && createdAt) {
const d = new Date(createdAt);
dbCreatedAtEl.textContent =
d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }) +
" " +
d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -385,7 +464,7 @@ function tickCooldown() {
} }
export function startCooldown() { export function startCooldown() {
const secs = GAME_CONFIG.clickCooldownSeconds; const secs = getEffectiveCooldown();
if (secs <= 0) { if (secs <= 0) {
teamCooldownEndMs[currentTeam] = 0; teamCooldownEndMs[currentTeam] = 0;
countdownWrap.classList.add("hidden"); countdownWrap.classList.add("hidden");

View File

@@ -9,6 +9,10 @@ import {
updateEconomyDisplay, updateEconomyDisplay,
tickEconScore, tickEconScore,
loadEconScores, loadEconScores,
loadVictoryPoints,
loadDbInfo,
loadElementBonus,
tickElementBonus,
refreshFromServer, refreshFromServer,
refreshGridDisplay, refreshGridDisplay,
loadPlayfieldMask, loadPlayfieldMask,
@@ -58,6 +62,7 @@ function scheduleScorePoll() {
scorePollTimer = window.setTimeout(async () => { scorePollTimer = window.setTimeout(async () => {
await fetchAndApplyScores(); await fetchAndApplyScores();
tickEconScore(ECON_TICK_SECONDS); tickEconScore(ECON_TICK_SECONDS);
tickElementBonus();
scheduleScorePoll(); scheduleScorePoll();
}, ECON_TICK_SECONDS * 1_000); }, ECON_TICK_SECONDS * 1_000);
} }
@@ -144,6 +149,9 @@ async function boot() {
await fetchGridForSeed(seedStr); await fetchGridForSeed(seedStr);
await fetchAndApplyScores(); await fetchAndApplyScores();
await loadEconScores(); await loadEconScores();
await loadVictoryPoints();
await loadDbInfo();
await loadElementBonus();
updateEconomyDisplay(); updateEconomyDisplay();
} catch { } catch {
hint.textContent = "API unavailable — start the Node server (docker-compose up --build)."; hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
@@ -159,6 +167,9 @@ async function boot() {
scheduleConfigPoll(); scheduleConfigPoll();
scheduleScorePoll(); scheduleScorePoll();
// Refresh VP every 30 s so new awards are reflected promptly
setInterval(loadVictoryPoints, 30_000);
// Refresh grid every second so all clients see new tiles promptly // Refresh grid every second so all clients see new tiles promptly
setInterval(refreshGridDisplay, 1_000); setInterval(refreshGridDisplay, 1_000);
} }

View File

@@ -276,61 +276,94 @@ body {
.scoreBoard { .scoreBoard {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 16px; gap: 8px;
padding: 10px 16px; padding: 10px 12px;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
font-size: 22px;
font-weight: 800;
font-family: "Courier New", Courier, monospace; font-family: "Courier New", Courier, monospace;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: 0.5px; }
.team-logo {
width: 54px;
height: 54px;
display: block;
flex-shrink: 0;
} }
.scoreTeam { .scoreTeam {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 4px;
gap: 10px; flex: 1;
}
.team-logo {
width: 64px;
height: 64px;
display: block;
} }
.scoreTeamName { .scoreTeamName {
font-size: 12px; font-size: 10px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
opacity: 0.8; opacity: 0.75;
} }
.scoreTeam--blue .scoreTeamName { .scoreTeam--blue .scoreTeamName { color: rgba(90, 200, 255, 0.9); }
color: rgba(90, 200, 255, 0.9); .scoreTeam--red .scoreTeamName { color: rgba(220, 75, 85, 0.9); }
.scoreStats {
display: flex;
gap: 10px;
align-items: flex-end;
}
.scoreStat {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.scoreStatVal {
font-size: 20px;
font-weight: 800;
line-height: 1;
}
.scoreStatLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
} }
.scoreTeam--blue .scoreValue { .scoreTeam--blue .scoreValue {
color: rgba(90, 200, 255, 1); color: rgba(90, 200, 255, 1);
text-shadow: 0 0 20px rgba(90, 200, 255, 0.4); text-shadow: 0 0 16px rgba(90, 200, 255, 0.4);
} }
.scoreTeam--red .scoreTeamName { .scoreTeam--blue .scoreVP {
color: rgba(220, 75, 85, 0.9); color: rgba(130, 230, 130, 1);
text-shadow: 0 0 16px rgba(90, 200, 130, 0.35);
} }
.scoreTeam--red .scoreValue { .scoreTeam--red .scoreValue {
color: rgba(220, 75, 85, 1); color: rgba(220, 75, 85, 1);
text-shadow: 0 0 20px rgba(220, 75, 85, 0.4); text-shadow: 0 0 16px rgba(220, 75, 85, 0.4);
}
.scoreTeam--red .scoreVP {
color: rgba(230, 150, 80, 1);
text-shadow: 0 0 16px rgba(220, 130, 60, 0.35);
} }
.scoreSep { .scoreSep {
opacity: 0.4; opacity: 0.35;
font-size: 18px; font-size: 18px;
font-weight: 800;
flex-shrink: 0;
} }
/* ── Main app layout ──────────────────────────────────────────────────────── */ /* ── Main app layout ──────────────────────────────────────────────────────── */
@@ -402,33 +435,36 @@ body {
.infoTable { .infoTable {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
font-family: "Courier New", Courier, monospace; font-family: "Courier New", Courier, monospace;
} }
.infoRow { .infoRow {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 8px; gap: 0;
min-height: 22px; min-height: 22px;
} }
.infoKey { .infoKey {
flex: 0 0 auto; flex: 0 0 55%;
font-size: 11px; font-size: 11px;
opacity: 0.65; opacity: 0.65;
white-space: nowrap; white-space: nowrap;
min-width: 130px;
text-align: right; text-align: right;
padding-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
} }
.infoVal { .infoVal {
flex: 1; flex: 0 0 45%;
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-family: "Courier New", Courier, monospace; font-family: "Courier New", Courier, monospace;
text-align: left;
} }
.infoVal code { .infoVal code {
@@ -801,6 +837,75 @@ button:hover {
font-weight: 700; font-weight: 700;
} }
/* ── Element bonus section ────────────────────────────────────────────────── */
.elemBonusSection {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 220, 100, 0.15);
background: rgba(255, 200, 50, 0.04);
font-family: "Courier New", Courier, monospace;
font-size: 12px;
}
.elemBonusSectionTitle {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.7;
margin-bottom: 2px;
}
.elemBonusRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.elemBonusTeam {
display: flex;
align-items: center;
gap: 5px;
}
.elemBonusTeam--blue .elemBonusLabel { color: rgba(90, 200, 255, 0.75); font-size: 10px; }
.elemBonusTeam--blue .elemBonusVal { color: rgba(90, 200, 255, 1); font-weight: 800; font-size: 13px; }
.elemBonusTeam--red .elemBonusLabel { color: rgba(220, 75, 85, 0.75); font-size: 10px; }
.elemBonusTeam--red .elemBonusVal { color: rgba(220, 75, 85, 1); font-weight: 800; font-size: 13px; }
.elemBonusUnit {
font-size: 10px;
opacity: 0.6;
}
.elemBonusSep {
opacity: 0.3;
}
.elemBonusDetail {
display: flex;
align-items: center;
gap: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 6px;
margin-top: 2px;
font-size: 11px;
}
.elemBonusDetailLabel {
opacity: 0.55;
}
.elemBonusDetailVal {
font-weight: 700;
color: rgba(255, 220, 100, 0.9);
}
/* ── Options / admin section ──────────────────────────────────────────────── */ /* ── Options / admin section ──────────────────────────────────────────────── */
.infoSection--options { .infoSection--options {
@@ -984,11 +1089,14 @@ canvas {
/* Scoreboard wraps nicely on narrow screens */ /* Scoreboard wraps nicely on narrow screens */
.scoreBoard { .scoreBoard {
flex-wrap: wrap; flex-wrap: wrap;
font-size: 18px;
} }
.team-logo { .team-logo {
width: 44px; width: 40px;
height: 44px; height: 40px;
}
.scoreStatVal {
font-size: 16px;
} }
} }

View File

@@ -13,6 +13,7 @@ let cached = {
databaseWipeoutIntervalSeconds: 21600, databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true, debugModeForTeams: true,
configReloadIntervalSeconds: 30, configReloadIntervalSeconds: 30,
elementWorth: {},
resourceWorth: { common: {}, rare: {} }, resourceWorth: { common: {}, rare: {} },
}; };
@@ -47,6 +48,9 @@ export function loadConfigFile() {
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) { if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds; cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
} }
if (j.elementWorth && typeof j.elementWorth === "object") {
cached.elementWorth = j.elementWorth;
}
if (j.resourceWorth && typeof j.resourceWorth === "object") { if (j.resourceWorth && typeof j.resourceWorth === "object") {
cached.resourceWorth = j.resourceWorth; cached.resourceWorth = j.resourceWorth;
} }

View File

@@ -37,6 +37,31 @@ export async function initGameSchema() {
PRIMARY KEY (world_seed, team) PRIMARY KEY (world_seed, team)
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS team_element_bonus (
world_seed TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
bonus DOUBLE PRECISION NOT NULL DEFAULT 0,
PRIMARY KEY (world_seed, team)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS db_metadata (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO db_metadata (created_at)
SELECT NOW() WHERE NOT EXISTS (SELECT 1 FROM db_metadata);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS victory_points (
id SERIAL PRIMARY KEY,
awarded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
world_seed TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('blue', 'red'))
);
CREATE INDEX IF NOT EXISTS idx_vp_team ON victory_points (team);
`);
await pool.query(` await pool.query(`
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT; ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS discovered_by TEXT;
UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL; UPDATE grid_cells SET discovered_by = 'blue' WHERE discovered_by IS NULL;
@@ -65,9 +90,30 @@ export async function ensureSeedEpoch() {
return worldSeed; return worldSeed;
} }
if (seedSlot !== lastSeedSlot) { if (seedSlot !== lastSeedSlot) {
// Award a victory point to the team with the highest econ score before wiping
try {
const expiredSeed = `swg-${lastSeedSlot}`;
const econRows = await pool.query(
`SELECT team, score FROM team_econ_scores WHERE world_seed = $1`,
[expiredSeed]
);
const scores = { blue: 0, red: 0 };
for (const row of econRows.rows) scores[row.team] = Number(row.score);
if (scores.blue > 0 || scores.red > 0) {
const winner = scores.blue >= scores.red ? "blue" : "red";
await pool.query(
`INSERT INTO victory_points (world_seed, team) VALUES ($1, $2)`,
[expiredSeed, winner]
);
console.log(`[world] VP awarded to ${winner} for seed ${expiredSeed} (blue=${scores.blue.toFixed(3)}, red=${scores.red.toFixed(3)})`);
}
} catch (e) {
console.error("[world] VP award error:", e);
}
await pool.query("TRUNCATE grid_cells RESTART IDENTITY"); await pool.query("TRUNCATE grid_cells RESTART IDENTITY");
await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]); await pool.query("DELETE FROM team_cooldowns WHERE world_seed != $1", [worldSeed]);
await pool.query("DELETE FROM team_econ_scores WHERE world_seed != $1", [worldSeed]); await pool.query("DELETE FROM team_econ_scores WHERE world_seed != $1", [worldSeed]);
await pool.query("DELETE FROM team_element_bonus WHERE world_seed != $1", [worldSeed]);
console.log(`[world] Slot ${lastSeedSlot}${seedSlot}; grid wiped, old cooldowns cleared.`); console.log(`[world] Slot ${lastSeedSlot}${seedSlot}; grid wiped, old cooldowns cleared.`);
lastSeedSlot = seedSlot; lastSeedSlot = seedSlot;
} }
@@ -147,6 +193,46 @@ export async function addEconScore(worldSeed, team, delta) {
); );
} }
export async function getElementBonus(worldSeed) {
const { rows } = await pool.query(
`SELECT team, bonus FROM team_element_bonus WHERE world_seed = $1`,
[worldSeed]
);
const result = { blue: 0, red: 0 };
for (const row of rows) result[row.team] = Number(row.bonus);
return result;
}
export async function setElementBonus(worldSeed, team, bonus) {
await pool.query(
`INSERT INTO team_element_bonus (world_seed, team, bonus)
VALUES ($1, $2, $3)
ON CONFLICT (world_seed, team) DO UPDATE
SET bonus = EXCLUDED.bonus`,
[worldSeed, team, bonus]
);
}
// ── DB metadata ───────────────────────────────────────────────────────────────
export async function getDbCreatedAt() {
const { rows } = await pool.query(
`SELECT created_at FROM db_metadata ORDER BY id ASC LIMIT 1`
);
return rows[0]?.created_at ?? null;
}
// ── Victory points ────────────────────────────────────────────────────────────
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 };
for (const row of rows) result[row.team] = Number(row.cnt);
return result;
}
// ── Scores ──────────────────────────────────────────────────────────────────── // ── Scores ────────────────────────────────────────────────────────────────────
export async function getScores(worldSeed) { export async function getScores(worldSeed) {
@@ -157,4 +243,4 @@ export async function getScores(worldSeed) {
[worldSeed] [worldSeed]
); );
return rows; return rows;
} }

View File

@@ -11,6 +11,10 @@ import {
getScores, getScores,
getEconScores, getEconScores,
addEconScore, addEconScore,
getElementBonus,
setElementBonus,
getDbCreatedAt,
getVictoryPoints,
} from "../db/gameDb.js"; } from "../db/gameDb.js";
import { computeCell, rowToCellPayload } from "../helpers/cell.js"; import { computeCell, rowToCellPayload } from "../helpers/cell.js";
@@ -46,6 +50,7 @@ router.get("/config", async (req, res) => {
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc, seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
teamCooldownRemaining, teamCooldownRemaining,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} }, resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
elementWorth: cfg.elementWorth ?? {},
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -180,6 +185,58 @@ router.post("/econ-scores/tick", async (req, res) => {
} }
}); });
// GET /api/element-bonus
router.get("/element-bonus", async (_req, res) => {
try {
const worldSeed = await ensureSeedEpoch();
const bonus = await getElementBonus(worldSeed);
res.json(bonus);
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// POST /api/element-bonus/tick body: { seed, blue, red }
router.post("/element-bonus/tick", async (req, res) => {
const seed = String(req.body?.seed ?? "");
const blue = Number(req.body?.blue ?? 0);
const red = Number(req.body?.red ?? 0);
try {
const worldSeed = await ensureSeedEpoch();
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
await setElementBonus(worldSeed, "blue", blue);
await setElementBonus(worldSeed, "red", red);
const bonus = await getElementBonus(worldSeed);
res.json(bonus);
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// GET /api/db-info
router.get("/db-info", async (_req, res) => {
try {
const createdAt = await getDbCreatedAt();
res.json({ createdAt });
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// GET /api/victory-points
router.get("/victory-points", async (_req, res) => {
try {
const vp = await getVictoryPoints();
res.json(vp);
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// GET /api/scores // GET /api/scores
router.get("/scores", async (_req, res) => { router.get("/scores", async (_req, res) => {
try { try {