Private
Public Access
1
0

Compare commits

17 Commits

Author SHA1 Message Date
gauvainboiche
e28a2d6e9c feat: Adding the name of capturers of planets + displaying usernames for each team 2026-04-01 17:46:48 +02:00
gauvainboiche
362aa07f5a fix: DB wipeout to 1 week again 2026-04-01 17:20:37 +02:00
gauvainboiche
99d34c58c6 refacto: Changing gameplay so teams don't know what the other team got + both teams can now chart the same cell without knowing + planet capture gameplay implemented 2026-04-01 17:17:52 +02:00
gauvainboiche
5aa347eb13 refacto: Changing click cooldown to daily actions for users and teams 2026-04-01 16:30:35 +02:00
gauvainboiche
33c3518ee4 fix(graphism): Better playground.svg to play on 2026-04-01 15:31:24 +02:00
gauvainboiche
198f03dfb7 feat(graphism): Adding favicon and title image 2026-04-01 14:38:15 +02:00
gauvainboiche
1dc6f3cc9e fix: Adding color fade-in for tile reveal 2026-03-31 23:35:08 +02:00
gauvainboiche
bbd82a8012 fix: Changing player color while connected 2026-03-31 23:01:18 +02:00
gauvainboiche
25a834adcc feat: Adding credits and rules sections 2026-03-31 18:46:07 +02:00
gauvainboiche
f7c0915661 fix(screen): Flex-start for infoColumn 2026-03-31 18:30:13 +02:00
gauvainboiche
570d83c3c0 feat(gameplay): Adding a military power section to exploit population numbers and steal ennemy tiles 2026-03-31 18:27:08 +02:00
gauvainboiche
e04560c7f9 fix: Adding README infos about the JWT token generation 2026-03-31 17:00:14 +02:00
gauvainboiche
7fa41ef7ac refacto: Deleting email mentions as there's no emailing system 2026-03-31 16:54:11 +02:00
gauvainboiche
42e68db00b refacto: Making the map zoomable on both desktop and mobile 2026-03-31 16:48:05 +02:00
gauvainboiche
d1240adbb7 refacto: Displaying number of players for each team + adding logo at registration 2026-03-31 16:35:08 +02:00
gauvainboiche
fa4fec3a11 refacto: Deleting unecessary mentions and setting DB wipeout to 1 week before live production 2026-03-31 16:14:07 +02:00
gauvainboiche
a810906bcb refacto: Changing some docker images to hardened non-root ones + README update 2026-03-31 15:01:50 +02:00
43 changed files with 3560 additions and 422 deletions

View File

@@ -15,9 +15,5 @@ POSTGRES_USERS_USER=users
POSTGRES_USERS_PASSWORD=CHANGE_ME
POSTGRES_USERS_DB=star_wars_users
# ── Admin ────────────────────────────────────────────────────────────────────
# Password to unlock the team-switching debug widget in the UI
ADMIN_PASSWORD=CHANGE_ME
# ── CORS ─────────────────────────────────────────────────────────────────────
CORS_ORIGIN=*

View File

@@ -1,29 +1,28 @@
FROM node:20-alpine
# Create non-root user/group before switching context
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# ── Stage 1: install production dependencies ──────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
# Install dependencies first for better layer caching
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
# ── Stage 2: hardened, minimal runtime ────────────────────────────────────────
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Copy application source
COPY server ./server
COPY public ./public
COPY config ./config
# Drop to non-root user
USER appuser
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
# Health-check: lightweight wget is available in node:alpine
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:8080/api/config > /dev/null || exit 1
CMD ["/nodejs/bin/node", "server/healthcheck.js"]
CMD ["node", "server/index.js"]
CMD ["server/index.js"]

362
README.md
View File

@@ -4,119 +4,313 @@ Exploitable ring only (outer Ø100, inner Ø60). Planet positions are determinis
> **Desktop only** — the galaxy map is not playable on phones or narrow screens (< 768 px wide).
## Layout
---
The UI is split into two panes:
## Table of contents
1. [Architecture](#architecture)
2. [Prerequisites](#prerequisites)
3. [Deploy on a new server](#deploy-on-a-new-server)
4. [Environment variables](#environment-variables)
5. [Runtime config (`game.settings.json`)](#runtime-config-gamesettingsjson)
6. [Local development (without Docker)](#local-development-without-docker)
7. [UI layout](#ui-layout)
8. [Game mechanics](#game-mechanics)
9. [API reference](#api-reference)
10. [Docker image notes](#docker-image-notes)
11. [Database persistence](#database-persistence)
---
## Architecture
| Component | Technology |
|-----------|-----------|
| Web server / API | Node.js 20 + Express |
| Game database | PostgreSQL 16 (`star_wars_grid`) |
| Users database | PostgreSQL 16 (`star_wars_users`) |
| Auth | JWT (7-day tokens, signed with `JWT_SECRET`) |
| Config hot-reload | File mtime polling (`game.settings.json`) |
| Economy engine | Server-side tick loop (every 5 s) |
| Container runtime | Docker / Docker Compose |
The app runs as a single Node process that serves both the static frontend (`public/`) and the REST API (`/api/*`). Two separate PostgreSQL instances hold game state and user accounts respectively.
---
## Prerequisites
| Requirement | Minimum version |
|-------------|----------------|
| Docker Engine | 24+ |
| Docker Compose plugin | v2 (`docker compose`) |
| Git | any recent |
No Node.js or PostgreSQL installation is needed on the host — everything runs inside Docker.
---
## Deploy on a new server
### 1 — Clone the repository
```bash
git clone <repo-url> star-wars-wild-space
cd star-wars-wild-space
```
### 2 — Create the environment file
```bash
cp .env.example .env
```
Open `.env` and set every value — see [Environment variables](#environment-variables) for details. At a minimum you **must** change:
- `JWT_SECRET` — generate a strong random string:
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```
You may use alternatives for Linux-based distros, like this OpenSSL one:
```bash
openssl rand -hex 48
```
Or even lower-level tools from Linux/UNIX entropy pool:
```bash
head -c 48 /dev/urandom | xxd -p -c 48
# or
head -c 48 /dev/urandom | hexdump -e '48/1 "%02x"'
```
- `POSTGRES_PASSWORD`
- `POSTGRES_USERS_PASSWORD`
- `ADMIN_PASSWORD`
### 3 — Review the game config
`config/game.settings.json` is mounted into the container and hot-reloaded at runtime. Adjust values before first boot if needed — see [Runtime config](#runtime-config-gamesettingsjson).
### 4 — Create the data directories
Docker bind-mounts the PostgreSQL data directories from the host. Create them before the first run so that Docker does not create them as root:
```bash
mkdir -p data/postgres data/postgres_users
```
### 5 — Build and start
```bash
docker compose up --build -d
```
The `app` service waits for both databases to pass their health checks before starting (pg_isready polling every 3 s, up to 15 retries). On first boot the server automatically creates all required tables.
### 6 — Verify the deployment
```bash
# Check all three containers are running
docker compose ps
# Tail logs
docker compose logs -f app
# Health check (should return JSON)
curl http://localhost:8080/api/config
```
Open `http://<server-ip>:8080` in a browser.
### 7 — (Optional) Reverse proxy
To expose the app on port 80/443, put Nginx or Caddy in front and proxy to `http://localhost:8080`. Set `CORS_ORIGIN` in `.env` to your public domain.
### Stopping / restarting
```bash
docker compose down # stop (data preserved)
docker compose down -v # stop and remove named volumes (data lost)
docker compose restart app # restart only the app container
```
---
## Environment variables
**Never commit secrets.** All credentials live in **`.env`** (git-ignored). Copy from `.env.example` before the first run.
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | HTTP port exposed by the app |
| `JWT_SECRET` | *(required)* | Secret used to sign JWT tokens — use a long random string in production |
| `POSTGRES_USER` | `game` | Game-DB username |
| `POSTGRES_PASSWORD` | *(required)* | Game-DB password |
| `POSTGRES_DB` | `star_wars_grid` | Game-DB name |
| `POSTGRES_USERS_USER` | `users` | Users-DB username |
| `POSTGRES_USERS_PASSWORD` | *(required)* | Users-DB password |
| `POSTGRES_USERS_DB` | `star_wars_users` | Users-DB name |
| `CORS_ORIGIN` | `*` | Value of the `Access-Control-Allow-Origin` header — set to your public domain in production |
---
## Runtime config (`game.settings.json`)
Edit **`config/game.settings.json`** on the host (bind-mounted into the container at `/app/config/game.settings.json`). The server checks the file's **mtime** on a schedule; when it changes the new values are applied without a restart, within at most `configReloadIntervalSeconds` seconds (minimum 5 s).
### Core fields
| Field | Default | Meaning |
|-------|---------|---------|
| `clickCooldownSeconds` | `10` | Per-user cooldown in seconds between new tile reveals. `0` disables it. |
| `databaseWipeoutIntervalSeconds` | `21600` | World period length in seconds (6 h). The world seed is `swg-<slot>` where `slot = floor(UTC unix seconds / this value)`. When the slot advances, `grid_cells` is truncated and a new era begins. Minimum `60`. |
| `configReloadIntervalSeconds` | `30` | How often the server polls the config file; also used by the client to poll `/api/config`. Minimum `5`. |
### Economy fields
| Field | Meaning |
|-------|---------|
| `elementWorth` | Per-element multipliers (`common`, `food`, `petrol`, `goods`, `industry`, `medic`, `money`, `science`). |
| `resourceWorth.common` / `resourceWorth.rare` | Base income per resource type for common and rare planet tiers. Drives the server-side economy tick (every 5 s). |
`GET /api/config` returns the core fields plus **`worldSeed`**, **`seedPeriodStartsAtUtc`**, **`seedPeriodEndsAtUtc`**, and economy values.
---
## Local development (without Docker)
Requires two running PostgreSQL 16 instances, then:
```bash
cp .env.example .env # fill in all values
npm install
# Set connection strings (PowerShell example)
$env:DATABASE_URL = "postgres://game:game@localhost:5432/star_wars_grid"
$env:USERS_DATABASE_URL = "postgres://users:users@localhost:5433/star_wars_users"
$env:JWT_SECRET = "dev_secret_change_me"
npm start
```
The server creates tables automatically on first run. Ensure `config/game.settings.json` exists (the file is included in the repository).
### Ports (local)
| Service | Port |
|---------|------|
| App | `8080` (or `PORT` env var) |
| Game Postgres | `5432` |
| Users Postgres | `5433` |
---
## UI layout
| Pane | Content |
|------|---------|
| **Left column** | Title, team scores, player info, cooldown timer, world seed & period countdown, Refresh button, selection details |
| **Right side** | Galaxy map — a perfect square anchored to the right edge of the viewport |
## Secrets & environment variables
---
**Never commit secrets.** All credentials live in **`.env`** (git-ignored).
Copy the template and fill in your values before the first run:
## Game mechanics
```powershell
cp .env.example .env
# then edit .env
### Teams (Résistance / Premier Ordre)
- Users register with a team (`Résistance` or `Premier Ordre`) that is locked to their account and embedded in their JWT.
- **Your** discovered tiles use your team colour. Unclaimed ring tiles use the neutral idle tint.
- Tiles discovered by the **other** team appear grey, show no planet, and are not clickable. The first reveal owns the tile (`discovered_by`).
### Cooldown
After revealing a **new** tile, a per-user cooldown starts. During cooldown you cannot reveal additional unseen tiles, but you can still click tiles your team has already discovered to view their stats. The effective cooldown is reduced by the team's element bonus:
```
effectiveCooldown = clickCooldownSeconds / (1 + elementBonus / 100)
```
| Variable | Description |
|----------|-------------|
| `JWT_SECRET` | Secret used to sign JWT tokens — use a long random string in production |
| `POSTGRES_USER` | Game-DB username (default `game`) |
| `POSTGRES_PASSWORD` | Game-DB password — **change in production** |
| `POSTGRES_DB` | Game-DB name (default `star_wars_grid`) |
| `POSTGRES_USERS_USER` | Users-DB username (default `users`) |
| `POSTGRES_USERS_PASSWORD` | Users-DB password — **change in production** |
| `POSTGRES_USERS_DB` | Users-DB name (default `star_wars_users`) |
| `PORT` | HTTP port exposed by the app (default `8080`) |
### World seed & wipe
## Runtime config (file + volume)
The world seed changes automatically when the UTC period slot advances. On a seed change the server truncates `grid_cells` and starts a fresh era. Data in `users`, cooldown tables, econ scores, and victory points is kept across wipes.
Edit **`config/game.settings.json`** on the host (mounted into the container at `/app/config/game.settings.json`). The server reloads it when the file's **mtime** changes, on a schedule controlled by **`configReloadIntervalSeconds`** (minimum 5 s), so frequent polling is avoided when nothing changed.
### Economy
| Field | Meaning |
|-------|---------|
| `clickCooldownSeconds` | Cooldown between **new** reveals (same as before). |
| `databaseWipeoutIntervalSeconds` | World period length in **seconds** (default `21600` = 6 h). The **world seed** is `swg-<slot>` with `slot = floor(UTC unix seconds / this value)`. When the slot changes, **`grid_cells` is truncated** (full wipe). |
| `debugModeForTeams` | If `true`, the **Blue / Red** segmented control is shown; if `false`, it is hidden. |
| `configReloadIntervalSeconds` | How often the server **checks** the config file (mtime); also used by the client to poll `/api/config`. |
A server-side tick runs every 5 seconds. It reads the current grid from the database, computes income and element bonus for each team from their discovered planets, and persists the deltas. No browser needs to be connected for the economy to progress.
`GET /api/config` returns these values plus **`worldSeed`**, **`seedPeriodEndsAtUtc`**, **`seedPeriodStartsAtUtc`**.
---
## Teams (blue / red)
## API reference
- With **`debugModeForTeams`**: use the **Team** control (top-left) to switch perspective.
- **Your** discovered tiles use your team colour. Unclaimed ring tiles use the classic idle tint for both teams.
- Tiles discovered by the **other** team appear **grey**, show **no planet**, and are **not clickable**. First reveal **owns** the tile (`discovered_by`).
All endpoints are relative to the app root.
## Cooldown
### Public
After revealing a **new** tile, a **cooldown** runs (left column). During cooldown you **cannot reveal additional unseen tiles**, but you can still **click tiles your team already discovered** to view their stats.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/config` | Game config plus current world seed and period timestamps. Accepts optional `?team=blue|red` and Bearer token to compute per-user cooldown remaining. |
| `GET` | `/api/grid/:seed` | All revealed cells for `seed`. Returns `410` if `seed` is not the current world seed. |
| `GET` | `/api/scores` | `{ blue: N, red: N }` — tile counts for the current period. |
| `GET` | `/api/econ-scores` | Economic score totals for the current period. |
| `GET` | `/api/element-bonus` | Current element bonus percentages per team. |
| `GET` | `/api/victory-points` | All awarded victory points across eras. |
| `GET` | `/api/db-info` | Database creation timestamp. |
## Run with Docker Compose (Node + PostgreSQL)
### Auth (no token required)
### Prerequisites
| Method | Path | Body | Response |
|--------|------|------|----------|
| `POST` | `/api/auth/register` | `{ username, email, password, team }` | `{ token, user }` |
| `POST` | `/api/auth/login` | `{ username, password }` | `{ token, user }` |
1. Copy the environment template and set your secrets:
```powershell
cp star_wars_grid_game/.env.example star_wars_grid_game/.env
# Edit .env — at minimum change JWT_SECRET, POSTGRES_PASSWORD, POSTGRES_USERS_PASSWORD
```
### Auth (Bearer token required)
2. From `star_wars_grid_game/`:
```powershell
docker compose up --build
```
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/auth/me` | Returns refreshed `{ token, user }`. |
| `POST` | `/api/cell/reveal` | Body `{ seed, x, y }`. Team is taken from the JWT. First reveal wins; **`409`** if the other team already owns the tile; **`410`** if seed is stale; **`429`** if on cooldown. |
3. Open `http://localhost:8080`.
### Auth field rules
### Ports
| Field | Rules |
|-------|-------|
| `username` | 232 characters |
| `password` | Minimum 6 characters |
| `team` | `"Résistance"` or `"Premier Ordre"` |
| `email` | Must be unique |
| Service | Port | Notes |
|---------|------|-------|
| App | `8080` | configurable via `PORT` in `.env` |
| Game Postgres | `5432` | user/pass from `.env` |
| Users Postgres | `5433` | user/pass from `.env` |
Tokens are valid for **7 days**. Call `GET /api/auth/me` to silently renew.
### Database persistence
PostgreSQL data is under **`./data/postgres`** and **`./data/postgres_users`** (bind mounts). The **world wipe** only clears `grid_cells` when the UTC period slot changes; it does not delete the Postgres data directory.
## Local dev (without Docker)
Requires PostgreSQL, then:
```powershell
cd star_wars_grid_game
cp .env.example .env # fill in DATABASE_URL etc.
npm install
# export vars or use a tool like dotenv-cli
$env:DATABASE_URL="postgres://game:game@localhost:5432/star_wars_grid"
$env:USERS_DATABASE_URL="postgres://users:users@localhost:5433/star_wars_users"
$env:JWT_SECRET="dev_secret"
npm start
```
Ensure `config/game.settings.json` exists (or copy from the repo).
---
## Docker image notes
- Base image: `node:20-alpine`
- Runs as a **non-root user** (`appuser`) for security
- `NODE_ENV=production` is set inside the image
- Built-in **health check**: polls `GET /api/config` every 15 s (`wget`)
- Secrets (`JWT_SECRET`, DB passwords) are **never baked into the image** — pass them via `.env` / Compose environment
- Build uses a **two-stage** Dockerfile:
- Stage 1 (`node:20-alpine`): installs production dependencies via `npm ci`.
- Stage 2 (`gcr.io/distroless/nodejs20-debian12:nonroot`): minimal runtime with no shell or package manager; runs as UID 65532.
- `NODE_ENV=production` is set inside the image.
- Built-in health check: calls `server/healthcheck.js` via the Node binary every 15 s (no `wget`/`curl` — distroless has none).
- Secrets are **never baked into the image** — always passed via `.env` / Compose environment.
## API
---
- `GET /api/config` — cooldown, wipe interval, debug flag, poll interval, `worldSeed`, period timestamps.
- `GET /api/grid/:seed` — cells for that seed; **`410`** if `seed` is not the current world seed.
- `POST /api/cell/reveal` — body `{ seed, x, y, team: "blue" | "red" }` — first reveal wins; **`409`** if the other team owns the tile; **`410`** if seed is stale.
- `POST /api/auth/register` — `{ username, email, password, team }` → `{ token, user }`.
- `POST /api/auth/login` — `{ username, password }` → `{ token, user }`.
- `GET /api/auth/me` — Bearer token required → refreshed `{ token, user }`.
- `GET /api/scores` — `{ blue: N, red: N }` tile counts for the current period.
## Database persistence
PostgreSQL data lives in bind-mount directories on the host:
| Directory | Database |
|-----------|----------|
| `./data/postgres` | Game DB (`star_wars_grid`) |
| `./data/postgres_users` | Users DB (`star_wars_users`) |
A **world wipe** only truncates the `grid_cells` table when the UTC period slot changes; it does not touch the host data directories. Users, cooldowns, econ scores, and victory points survive wipes.
To fully reset the game state (including all user accounts), stop the stack and delete both data directories, then start again:
```bash
docker compose down
rm -rf data/postgres data/postgres_users
mkdir -p data/postgres data/postgres_users
docker compose up -d
```

View File

@@ -1,17 +1,22 @@
{
"clickCooldownSeconds": 10,
"databaseWipeoutIntervalSeconds": 21600,
"debugModeForTeams": true,
"dailyActionQuota": 100,
"teamActionQuota": 100,
"databaseWipeoutIntervalSeconds": 604800,
"configReloadIntervalSeconds": 30,
"elementWorth": {
"common": 1,
"food": 2,
"petrol": 3,
"goods": 4,
"industry": 5,
"medic": 6,
"money": 7,
"science": 8
"common": 0.1,
"food": 0.2,
"petrol": 0.3,
"goods": 0.4,
"industry": 0.5,
"medic": 0.6,
"money": 0.7,
"science": 0.8
},
"militaryPower": {
"humans": 10,
"near": 5,
"aliens": 1
},
"resourceWorth": {
"common": {

View File

@@ -46,6 +46,7 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
PORT: "${PORT:-8080}"
CONFIG_FILE_PATH: /app/config/game.settings.json
CORS_ORIGIN: ${CORS_ORIGIN:-*}
volumes:
- ./config:/app/config
depends_on:

26
node_modules/.package-lock.json generated vendored
View File

@@ -132,6 +132,23 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -641,6 +658,15 @@
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

22
node_modules/cors/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,22 @@
(The MIT License)
Copyright (c) 2013 Troy Goode <troygoode@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

277
node_modules/cors/README.md generated vendored Normal file
View File

@@ -0,0 +1,277 @@
# cors
[![NPM Version][npm-image]][npm-url]
[![NPM Downloads][downloads-image]][downloads-url]
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
[![Test Coverage][coveralls-image]][coveralls-url]
CORS is a [Node.js](https://nodejs.org/en/) middleware for [Express](https://expressjs.com/)/[Connect](https://github.com/senchalabs/connect) that sets [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) response headers. These headers tell browsers which origins can read responses from your server.
> [!IMPORTANT]
> **How CORS Works:** This package sets response headers—it doesn't block requests. CORS is enforced by browsers: they check the headers and decide if JavaScript can read the response. Non-browser clients (curl, Postman, other servers) ignore CORS entirely. See the [MDN CORS guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) for details.
* [Installation](#installation)
* [Usage](#usage)
* [Simple Usage](#simple-usage-enable-all-cors-requests)
* [Enable CORS for a Single Route](#enable-cors-for-a-single-route)
* [Configuring CORS](#configuring-cors)
* [Configuring CORS w/ Dynamic Origin](#configuring-cors-w-dynamic-origin)
* [Enabling CORS Pre-Flight](#enabling-cors-pre-flight)
* [Customizing CORS Settings Dynamically per Request](#customizing-cors-settings-dynamically-per-request)
* [Configuration Options](#configuration-options)
* [Common Misconceptions](#common-misconceptions)
* [License](#license)
* [Original Author](#original-author)
## Installation
This is a [Node.js](https://nodejs.org/en/) module available through the
[npm registry](https://www.npmjs.com/). Installation is done using the
[`npm install` command](https://docs.npmjs.com/downloading-and-installing-packages-locally):
```sh
$ npm install cors
```
## Usage
### Simple Usage (Enable *All* CORS Requests)
```javascript
var express = require('express')
var cors = require('cors')
var app = express()
// Adds headers: Access-Control-Allow-Origin: *
app.use(cors())
app.get('/products/:id', function (req, res, next) {
res.json({msg: 'Hello'})
})
app.listen(80, function () {
console.log('web server listening on port 80')
})
```
### Enable CORS for a Single Route
```javascript
var express = require('express')
var cors = require('cors')
var app = express()
// Adds headers: Access-Control-Allow-Origin: *
app.get('/products/:id', cors(), function (req, res, next) {
res.json({msg: 'Hello'})
})
app.listen(80, function () {
console.log('web server listening on port 80')
})
```
### Configuring CORS
See the [configuration options](#configuration-options) for details.
```javascript
var express = require('express')
var cors = require('cors')
var app = express()
var corsOptions = {
origin: 'http://example.com',
optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
}
// Adds headers: Access-Control-Allow-Origin: http://example.com, Vary: Origin
app.get('/products/:id', cors(corsOptions), function (req, res, next) {
res.json({msg: 'Hello'})
})
app.listen(80, function () {
console.log('web server listening on port 80')
})
```
### Configuring CORS w/ Dynamic Origin
This module supports validating the origin dynamically using a function provided
to the `origin` option. This function will be passed a string that is the origin
(or `undefined` if the request has no origin), and a `callback` with the signature
`callback(error, origin)`.
The `origin` argument to the callback can be any value allowed for the `origin`
option of the middleware, except a function. See the
[configuration options](#configuration-options) section for more information on all
the possible value types.
This function is designed to allow the dynamic loading of allowed origin(s) from
a backing datasource, like a database.
```javascript
var express = require('express')
var cors = require('cors')
var app = express()
var corsOptions = {
origin: function (origin, callback) {
// db.loadOrigins is an example call to load
// a list of origins from a backing database
db.loadOrigins(function (error, origins) {
callback(error, origins)
})
}
}
// Adds headers: Access-Control-Allow-Origin: <matched origin>, Vary: Origin
app.get('/products/:id', cors(corsOptions), function (req, res, next) {
res.json({msg: 'Hello'})
})
app.listen(80, function () {
console.log('web server listening on port 80')
})
```
### Enabling CORS Pre-Flight
Certain CORS requests are considered 'complex' and require an initial
`OPTIONS` request (called the "pre-flight request"). An example of a
'complex' CORS request is one that uses an HTTP verb other than
GET/HEAD/POST (such as DELETE) or that uses custom headers. To enable
pre-flighting, you must add a new OPTIONS handler for the route you want
to support:
```javascript
var express = require('express')
var cors = require('cors')
var app = express()
app.options('/products/:id', cors()) // preflight for DELETE
app.del('/products/:id', cors(), function (req, res, next) {
res.json({msg: 'Hello'})
})
app.listen(80, function () {
console.log('web server listening on port 80')
})
```
You can also enable pre-flight across-the-board like so:
```javascript
app.options('*', cors()) // include before other routes
```
NOTE: When using this middleware as an application level middleware (for
example, `app.use(cors())`), pre-flight requests are already handled for all
routes.
### Customizing CORS Settings Dynamically per Request
For APIs that require different CORS configurations for specific routes or requests, you can dynamically generate CORS options based on the incoming request. The `cors` middleware allows you to achieve this by passing a function instead of static options. This function is called for each incoming request and must use the callback pattern to return the appropriate CORS options.
The function accepts:
1. **`req`**:
- The incoming request object.
2. **`callback(error, corsOptions)`**:
- A function used to return the computed CORS options.
- **Arguments**:
- **`error`**: Pass `null` if theres no error, or an error object to indicate a failure.
- **`corsOptions`**: An object specifying the CORS policy for the current request.
Heres an example that handles both public routes and restricted, credential-sensitive routes:
```javascript
var dynamicCorsOptions = function(req, callback) {
var corsOptions;
if (req.path.startsWith('/auth/connect/')) {
// Access-Control-Allow-Origin: http://mydomain.com, Access-Control-Allow-Credentials: true, Vary: Origin
corsOptions = {
origin: 'http://mydomain.com',
credentials: true
};
} else {
// Access-Control-Allow-Origin: *
corsOptions = { origin: '*' };
}
callback(null, corsOptions);
};
app.use(cors(dynamicCorsOptions));
app.get('/auth/connect/twitter', function (req, res) {
res.send('Hello');
});
app.get('/public', function (req, res) {
res.send('Hello');
});
app.listen(80, function () {
console.log('web server listening on port 80')
})
```
## Configuration Options
* `origin`: Configures the **Access-Control-Allow-Origin** CORS header. Possible values:
- `Boolean` - set `origin` to `true` to reflect the [request origin](https://datatracker.ietf.org/doc/html/draft-abarth-origin-09), as defined by `req.header('Origin')`, or set it to `false` to disable CORS.
- `String` - set `origin` to a specific origin. For example, if you set it to
- `"http://example.com"` only requests from "http://example.com" will be allowed.
- `"*"` for all domains to be allowed.
- `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com".
- `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com".
- `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (called as `callback(err, origin)`, where `origin` is a non-function value of the `origin` option) as the second.
* `methods`: Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`).
* `allowedHeaders`: Configures the **Access-Control-Allow-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Type,Authorization') or an array (ex: `['Content-Type', 'Authorization']`). If not specified, defaults to reflecting the headers specified in the request's **Access-Control-Request-Headers** header.
* `exposedHeaders`: Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed.
* `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted.
* `maxAge`: Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted.
* `preflightContinue`: Pass the CORS preflight response to the next handler.
* `optionsSuccessStatus`: Provides a status code to use for successful `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`.
The default configuration is the equivalent of:
```json
{
"origin": "*",
"methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
"preflightContinue": false,
"optionsSuccessStatus": 204
}
```
## Common Misconceptions
### "CORS blocks requests from disallowed origins"
**No.** Your server receives and processes every request. CORS headers tell the browser whether JavaScript can read the response—not whether the request is allowed.
### "CORS protects my API from unauthorized access"
**No.** CORS is not access control. Any HTTP client (curl, Postman, another server) can call your API regardless of CORS settings. Use authentication and authorization to protect your API.
### "Setting `origin: 'http://example.com'` means only that domain can access my server"
**No.** It means browsers will only let JavaScript from that origin read responses. The server still responds to all requests.
## License
[MIT License](http://www.opensource.org/licenses/mit-license.php)
## Original Author
[Troy Goode](https://github.com/TroyGoode) ([troygoode@gmail.com](mailto:troygoode@gmail.com))
[coveralls-image]: https://img.shields.io/coveralls/expressjs/cors/master.svg
[coveralls-url]: https://coveralls.io/r/expressjs/cors?branch=master
[downloads-image]: https://img.shields.io/npm/dm/cors.svg
[downloads-url]: https://npmjs.com/package/cors
[github-actions-ci-image]: https://img.shields.io/github/actions/workflow/status/expressjs/cors/ci.yml?branch=master&label=ci
[github-actions-ci-url]: https://github.com/expressjs/cors?query=workflow%3Aci
[npm-image]: https://img.shields.io/npm/v/cors.svg
[npm-url]: https://npmjs.com/package/cors

238
node_modules/cors/lib/index.js generated vendored Normal file
View File

@@ -0,0 +1,238 @@
(function () {
'use strict';
var assign = require('object-assign');
var vary = require('vary');
var defaults = {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204
};
function isString(s) {
return typeof s === 'string' || s instanceof String;
}
function isOriginAllowed(origin, allowedOrigin) {
if (Array.isArray(allowedOrigin)) {
for (var i = 0; i < allowedOrigin.length; ++i) {
if (isOriginAllowed(origin, allowedOrigin[i])) {
return true;
}
}
return false;
} else if (isString(allowedOrigin)) {
return origin === allowedOrigin;
} else if (allowedOrigin instanceof RegExp) {
return allowedOrigin.test(origin);
} else {
return !!allowedOrigin;
}
}
function configureOrigin(options, req) {
var requestOrigin = req.headers.origin,
headers = [],
isAllowed;
if (!options.origin || options.origin === '*') {
// allow any origin
headers.push([{
key: 'Access-Control-Allow-Origin',
value: '*'
}]);
} else if (isString(options.origin)) {
// fixed origin
headers.push([{
key: 'Access-Control-Allow-Origin',
value: options.origin
}]);
headers.push([{
key: 'Vary',
value: 'Origin'
}]);
} else {
isAllowed = isOriginAllowed(requestOrigin, options.origin);
// reflect origin
headers.push([{
key: 'Access-Control-Allow-Origin',
value: isAllowed ? requestOrigin : false
}]);
headers.push([{
key: 'Vary',
value: 'Origin'
}]);
}
return headers;
}
function configureMethods(options) {
var methods = options.methods;
if (methods.join) {
methods = options.methods.join(','); // .methods is an array, so turn it into a string
}
return {
key: 'Access-Control-Allow-Methods',
value: methods
};
}
function configureCredentials(options) {
if (options.credentials === true) {
return {
key: 'Access-Control-Allow-Credentials',
value: 'true'
};
}
return null;
}
function configureAllowedHeaders(options, req) {
var allowedHeaders = options.allowedHeaders || options.headers;
var headers = [];
if (!allowedHeaders) {
allowedHeaders = req.headers['access-control-request-headers']; // .headers wasn't specified, so reflect the request headers
headers.push([{
key: 'Vary',
value: 'Access-Control-Request-Headers'
}]);
} else if (allowedHeaders.join) {
allowedHeaders = allowedHeaders.join(','); // .headers is an array, so turn it into a string
}
if (allowedHeaders && allowedHeaders.length) {
headers.push([{
key: 'Access-Control-Allow-Headers',
value: allowedHeaders
}]);
}
return headers;
}
function configureExposedHeaders(options) {
var headers = options.exposedHeaders;
if (!headers) {
return null;
} else if (headers.join) {
headers = headers.join(','); // .headers is an array, so turn it into a string
}
if (headers && headers.length) {
return {
key: 'Access-Control-Expose-Headers',
value: headers
};
}
return null;
}
function configureMaxAge(options) {
var maxAge = (typeof options.maxAge === 'number' || options.maxAge) && options.maxAge.toString()
if (maxAge && maxAge.length) {
return {
key: 'Access-Control-Max-Age',
value: maxAge
};
}
return null;
}
function applyHeaders(headers, res) {
for (var i = 0, n = headers.length; i < n; i++) {
var header = headers[i];
if (header) {
if (Array.isArray(header)) {
applyHeaders(header, res);
} else if (header.key === 'Vary' && header.value) {
vary(res, header.value);
} else if (header.value) {
res.setHeader(header.key, header.value);
}
}
}
}
function cors(options, req, res, next) {
var headers = [],
method = req.method && req.method.toUpperCase && req.method.toUpperCase();
if (method === 'OPTIONS') {
// preflight
headers.push(configureOrigin(options, req));
headers.push(configureCredentials(options))
headers.push(configureMethods(options))
headers.push(configureAllowedHeaders(options, req));
headers.push(configureMaxAge(options))
headers.push(configureExposedHeaders(options))
applyHeaders(headers, res);
if (options.preflightContinue) {
next();
} else {
// Safari (and potentially other browsers) need content-length 0,
// for 204 or they just hang waiting for a body
res.statusCode = options.optionsSuccessStatus;
res.setHeader('Content-Length', '0');
res.end();
}
} else {
// actual response
headers.push(configureOrigin(options, req));
headers.push(configureCredentials(options))
headers.push(configureExposedHeaders(options))
applyHeaders(headers, res);
next();
}
}
function middlewareWrapper(o) {
// if options are static (either via defaults or custom options passed in), wrap in a function
var optionsCallback = null;
if (typeof o === 'function') {
optionsCallback = o;
} else {
optionsCallback = function (req, cb) {
cb(null, o);
};
}
return function corsMiddleware(req, res, next) {
optionsCallback(req, function (err, options) {
if (err) {
next(err);
} else {
var corsOptions = assign({}, defaults, options);
var originCallback = null;
if (corsOptions.origin && typeof corsOptions.origin === 'function') {
originCallback = corsOptions.origin;
} else if (corsOptions.origin) {
originCallback = function (origin, cb) {
cb(null, corsOptions.origin);
};
}
if (originCallback) {
originCallback(req.headers.origin, function (err2, origin) {
if (err2 || !origin) {
next(err2);
} else {
corsOptions.origin = origin;
cors(corsOptions, req, res, next);
}
});
} else {
next();
}
}
});
};
}
// can pass either an options hash, an options delegate, or nothing
module.exports = middlewareWrapper;
}());

42
node_modules/cors/package.json generated vendored Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "cors",
"description": "Node.js CORS middleware",
"version": "2.8.6",
"author": "Troy Goode <troygoode@gmail.com> (https://github.com/troygoode/)",
"license": "MIT",
"keywords": [
"cors",
"express",
"connect",
"middleware"
],
"repository": "expressjs/cors",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
},
"main": "./lib/index.js",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"devDependencies": {
"after": "0.8.2",
"eslint": "7.30.0",
"express": "4.21.2",
"mocha": "9.2.2",
"nyc": "15.1.0",
"supertest": "6.1.3"
},
"files": [
"lib/index.js"
],
"engines": {
"node": ">= 0.10"
},
"scripts": {
"test": "npm run lint && npm run test-ci",
"test-ci": "nyc --reporter=lcov --reporter=text mocha --require test/support/env",
"lint": "eslint lib test"
}
}

90
node_modules/object-assign/index.js generated vendored Normal file
View File

@@ -0,0 +1,90 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
'use strict';
/* eslint-disable no-unused-vars */
var getOwnPropertySymbols = Object.getOwnPropertySymbols;
var hasOwnProperty = Object.prototype.hasOwnProperty;
var propIsEnumerable = Object.prototype.propertyIsEnumerable;
function toObject(val) {
if (val === null || val === undefined) {
throw new TypeError('Object.assign cannot be called with null or undefined');
}
return Object(val);
}
function shouldUseNative() {
try {
if (!Object.assign) {
return false;
}
// Detect buggy property enumeration order in older V8 versions.
// https://bugs.chromium.org/p/v8/issues/detail?id=4118
var test1 = new String('abc'); // eslint-disable-line no-new-wrappers
test1[5] = 'de';
if (Object.getOwnPropertyNames(test1)[0] === '5') {
return false;
}
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
var test2 = {};
for (var i = 0; i < 10; i++) {
test2['_' + String.fromCharCode(i)] = i;
}
var order2 = Object.getOwnPropertyNames(test2).map(function (n) {
return test2[n];
});
if (order2.join('') !== '0123456789') {
return false;
}
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
var test3 = {};
'abcdefghijklmnopqrst'.split('').forEach(function (letter) {
test3[letter] = letter;
});
if (Object.keys(Object.assign({}, test3)).join('') !==
'abcdefghijklmnopqrst') {
return false;
}
return true;
} catch (err) {
// We don't expect any of the above to throw, but better to be safe.
return false;
}
}
module.exports = shouldUseNative() ? Object.assign : function (target, source) {
var from;
var to = toObject(target);
var symbols;
for (var s = 1; s < arguments.length; s++) {
from = Object(arguments[s]);
for (var key in from) {
if (hasOwnProperty.call(from, key)) {
to[key] = from[key];
}
}
if (getOwnPropertySymbols) {
symbols = getOwnPropertySymbols(from);
for (var i = 0; i < symbols.length; i++) {
if (propIsEnumerable.call(from, symbols[i])) {
to[symbols[i]] = from[symbols[i]];
}
}
}
}
return to;
};

21
node_modules/object-assign/license generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

42
node_modules/object-assign/package.json generated vendored Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "object-assign",
"version": "4.1.1",
"description": "ES2015 `Object.assign()` ponyfill",
"license": "MIT",
"repository": "sindresorhus/object-assign",
"author": {
"name": "Sindre Sorhus",
"email": "sindresorhus@gmail.com",
"url": "sindresorhus.com"
},
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"test": "xo && ava",
"bench": "matcha bench.js"
},
"files": [
"index.js"
],
"keywords": [
"object",
"assign",
"extend",
"properties",
"es2015",
"ecmascript",
"harmony",
"ponyfill",
"prollyfill",
"polyfill",
"shim",
"browser"
],
"devDependencies": {
"ava": "^0.16.0",
"lodash": "^4.16.4",
"matcha": "^0.7.0",
"xo": "^0.16.0"
}
}

61
node_modules/object-assign/readme.md generated vendored Normal file
View File

@@ -0,0 +1,61 @@
# object-assign [![Build Status](https://travis-ci.org/sindresorhus/object-assign.svg?branch=master)](https://travis-ci.org/sindresorhus/object-assign)
> ES2015 [`Object.assign()`](http://www.2ality.com/2014/01/object-assign.html) [ponyfill](https://ponyfill.com)
## Use the built-in
Node.js 4 and up, as well as every evergreen browser (Chrome, Edge, Firefox, Opera, Safari),
support `Object.assign()` :tada:. If you target only those environments, then by all
means, use `Object.assign()` instead of this package.
## Install
```
$ npm install --save object-assign
```
## Usage
```js
const objectAssign = require('object-assign');
objectAssign({foo: 0}, {bar: 1});
//=> {foo: 0, bar: 1}
// multiple sources
objectAssign({foo: 0}, {bar: 1}, {baz: 2});
//=> {foo: 0, bar: 1, baz: 2}
// overwrites equal keys
objectAssign({foo: 0}, {foo: 1}, {foo: 2});
//=> {foo: 2}
// ignores null and undefined sources
objectAssign({foo: 0}, null, {bar: 1}, undefined);
//=> {foo: 0, bar: 1}
```
## API
### objectAssign(target, [source, ...])
Assigns enumerable own properties of `source` objects to the `target` object and returns the `target` object. Additional `source` objects will overwrite previous ones.
## Resources
- [ES2015 spec - Object.assign](https://people.mozilla.org/~jorendorff/es6-draft.html#sec-object.assign)
## Related
- [deep-assign](https://github.com/sindresorhus/deep-assign) - Recursive `Object.assign()`
## License
MIT © [Sindre Sorhus](https://sindresorhus.com)

27
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"name": "star-wars-wild-space",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
@@ -142,6 +143,23 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -651,6 +669,15 @@
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View File

@@ -7,6 +7,7 @@
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",

BIN
public/graphism/Logo_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

BIN
public/graphism/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1000"
height="1000"
viewBox="0 0 1000 1000"
version="1.1"
id="svg1"
sodipodi:docname="playground.svg"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.5867476"
inkscape:cx="178.66736"
inkscape:cy="601.86006"
inkscape:window-width="3440"
inkscape:window-height="1351"
inkscape:window-x="-9"
inkscape:window-y="222"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<defs
id="defs1">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4">
<path
d="M 0,2048 H 2048 V 0 H 0 Z"
transform="translate(-1507.918,-1029.2018)"
id="path4" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4-4">
<path
d="M 0,2048 H 2048 V 0 H 0 Z"
transform="translate(-1507.918,-1029.2018)"
id="path4-8" />
</clipPath>
</defs>
<path
id="outer_regions"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-width:0.51783;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers;enable-background:accumulate;stop-color:#000000"
d="M 421.56055 25 C 373.38507 24.854113 329.1082 52.904873 305.05664 94.201172 C 293.06758 105.95087 270.36232 134.71112 257.10352 150.71875 C 247.32899 164.49231 234.8216 176.43594 222.05469 226.12109 C 219.03118 242.36653 217.06491 259.07203 214.29883 275.56445 C 212.67557 298.98884 264.13768 311.51357 287.87891 327.09375 C 296.0849 332.47893 307.97987 340.00424 320.7832 348.60742 C 320.1134 344.11854 323.47791 339.10584 327.63672 332.05078 C 333.0299 323.75882 336.92332 323.45873 343.03125 314.89453 C 351.31162 309.1134 354.9907 298.11968 360.89844 289.64062 C 359.97627 280.01528 353.75632 277.99875 351.76367 273.21289 C 337.55097 259.56523 345.01527 239.46967 354.64648 226.36523 C 362.39976 217.92275 365.16934 208.4102 375.75195 201.58008 C 393.07795 200.38815 393.75913 182.17379 405.15039 169.01367 C 411.69598 149.88677 428.93477 147.15396 446.0293 152.33398 C 463.84346 150.58687 513.99705 149.58582 495.74609 178.20312 C 481.87191 191.89422 490.71187 216.23079 511.38086 216.18359 C 535.38757 220.51262 559.44416 197.25521 582.21484 210.84375 C 583.66092 187.31652 616.67414 194.17432 627.66992 211.77344 C 639.63565 222.2925 657.2255 200.75036 670.13086 212.12109 C 692.3961 211.1996 674.81659 227.38108 675.00195 237.70117 C 671.23258 256.47466 686.23931 264.12366 691.91797 280.63867 C 698.02993 281.73392 699.72808 289.02616 705.51367 291.75 C 719.70278 301.66027 728.05547 314.34223 743.5 321.95312 C 738.76526 335.61082 748.70232 356.30939 741.57227 373.19922 C 729.1255 388.20226 742.26702 383.34648 749.36719 389.08789 C 750.53135 401.37329 740.32006 397.65199 735.78711 409.5918 C 730.09592 428.34355 740.45242 447.3727 742.08789 461.16211 C 744.77627 479.10466 749.10645 479.06498 749.95312 497.29883 C 751.39719 514.8114 748.38528 531.58471 746.43359 548.65039 C 746.77291 565.35558 741.35189 582.18322 742.0332 597.92383 C 739.3678 611.44419 736.1463 628.11891 730.26172 639.51953 C 729.26253 661.79034 717.30912 682.89918 706.31055 702.10547 C 695.71562 721.15694 675.6338 730.68077 655.84375 737.67578 C 643.54509 741.03008 629.14988 736.21891 620.83008 747.55859 C 600.79718 739.99855 601.64775 757.25019 608.26758 772.82227 C 622.50221 792.38563 605.21984 809.86991 583.91211 799.55859 C 559.10376 790.70814 518.22734 773.90224 510.28516 814.05664 C 509.17234 841.04703 481.10084 820.19103 470.73047 836.43164 C 479.5771 853.61378 463.50343 850.19036 459.09766 837.0957 C 448.69254 826.70252 423.44592 823.91476 414.74219 835.57812 C 402.43796 835.08453 388.49386 847.09036 373.93359 849.48633 C 364.87672 842.34189 362.53209 828.37655 346.71289 824.5 C 348.92431 799.51325 316.89209 778.69284 303.48438 807.22656 C 276.36808 813.39829 298.46075 777.29162 301.49414 766.49609 C 297.28513 757.65654 318.0412 735.18774 306.83398 732.92969 C 308.06058 715.91282 288.52149 694.67408 268.26172 687.36914 C 232.49774 693.13392 263.89538 649.66269 268.03516 634.19141 C 270.66323 623.27925 272.49877 622.46824 273.95508 625.85742 C 275.96345 610.91265 276.07179 593.50984 267.37891 580.21484 C 252.2274 557.04193 223.70727 537.43372 188.05664 548.12891 C 181.42086 550.11964 169.81936 552.90135 164.4043 561.08984 C 144.58836 594.04992 132.26742 632.12959 126.00391 669.55664 C 119.77245 713.01501 143.65783 750.68967 167.35742 784.14453 C 155.33363 828.28331 180.22411 879.31446 216.54297 906.95312 C 246.91473 930.04449 286.64182 932.68349 314.79883 960.30469 C 351.47425 986.96424 395.09906 969.18658 435.38867 969.2793 C 488.61384 986.20754 538.18949 949.92996 583.54102 928.72656 C 626.36154 926.74102 673.9568 920.77568 702.33203 884.4668 C 720.04276 849.20725 753.55087 830.82081 782.38477 806.63281 C 816.72989 771.58231 838.33704 725.52218 851.42578 678.64062 C 853.74925 660.01959 870.72296 611.5352 869.11719 579.91211 C 876.51005 532.95074 878.33465 484.14554 866.31641 437.89844 C 879.98281 393.37 868.87111 347.08312 866.87109 301.52148 C 858.01092 246.34361 799.39416 215.12646 792.55078 158.82227 C 768.95243 110.68367 714.34642 83.955117 662.24805 84.765625 C 619.53959 69.32581 571.50126 70.255977 535.91602 38.785156 C 499.34325 25.027918 459.71597 28.170707 421.56055 25 z "
inkscape:label="outer_regions">
<title
id="title164">outer_regions</title>
</path>
<path
id="galaxy"
d="m 0,0 c -1.6392001,35.116515 -10.023207,35.039508 -15.228,69.595 -3.16633,26.556969 -23.215345,63.20707 -12.197,99.321 8.775939,22.99484 28.5458585,15.82859 26.292,39.489 -13.746171,11.05737 -39.190764,1.70514 -15.093391,30.59945 13.8040271,32.52806 -5.434238,72.38925 3.732391,98.69255 -29.901136,14.65779 -46.072395,39.08484 -73.543,58.171 -11.20111,5.24584 -14.48904,19.28766 -26.322,21.397 -10.99407,31.8062 -40.04663,46.53721 -32.749,82.693 -0.35886,19.87543 33.67428,51.0387 -9.432,49.264 -24.98523,21.89886 -59.03894,-19.58862 -82.205,0.67 -21.28822,33.89408 -85.20435,47.10102 -88.004,1.79 -44.08483,26.17012 -90.65619,-18.62026 -137.134,-10.283 -40.01587,-0.0909 -57.1319,46.77937 -30.271,73.147 35.33449,55.11398 -61.76417,53.18677 -96.253,49.822 -33.09559,9.97619 -66.47051,4.71143 -79.143,-32.125 -22.05388,-25.34503 -23.37227,-60.42446 -56.916,-62.72 -20.4883,-13.15411 -25.8494,-31.47265 -40.86,-47.732 -18.64637,-25.23778 -33.09729,-63.94102 -5.581,-90.225 3.85784,-9.21708 15.89965,-13.09958 17.685,-31.637 -11.43758,-16.32978 -18.56292,-37.50213 -34.594,-48.636 -11.82516,-16.49377 -19.36161,-17.07354 -29.803,-33.043 -14.73921,-24.87286 -24.32464,-36.55882 9.722,-53.02 -4.46635,-30.01183 27.85389,-43.74102 -4.50894,-76.21989 -41.15805,-9.92894 -28.31953,-49.82846 10.02294,-43.45411 27.71821,12.87712 84.09192,-10.4708 31.292,-27.711 -30.52347,-27.419057 -43.28626,-68.708385 11.977,-75.612 -2.55343,-31.955736 -0.49004,-67.4389 20.284,-94.317 -9.21704,-11.892963 -47.71154,-2.006868 -50.588,-24.1 18.68092,-14.240523 35.97904,-34.807 7.814,-52.503 -37.3362,18.82968 -27.89316,-49.1466 -38.85036,-77.57622 15.50352,-20.08451 47.6389,-25.39807 23.04336,-51.35178 19.90369,-23.02097 60.50378,-65.64143 2.124,-48.359 -43.82094,18.53403 -12.57036,-21.67302 -26.33425,-16.04163 -37.15002,-29.11066 -79.0999,-21.12707 -89.96725,29.91388 -5.73779,15.4801 -9.03267,79.63161 -21.4215,28.46075 -8.01474,-29.79609 -68.8023,-113.51537 0.438,-102.413 39.22362,-14.06855 77.05303,-54.97327 74.67829,-87.746 21.69756,-4.34878 -18.48907,-47.62093 -10.34029,-64.645 -5.87275,-20.79107 -48.64208,-90.32811 3.856,-78.442 25.95779,54.95301 87.97139,14.8539 83.69,-33.268 30.62651,-7.46583 35.16758,-34.36055 52.702,-48.12 28.18917,4.61439 55.18559,27.73461 79.007,26.784 16.85072,22.46244 65.72934,17.0922 85.874,-2.924 8.52973,-25.21895 39.647,-31.81127 22.51962,1.27975 20.0774,31.27774 74.42592,-8.88745 76.58038,43.09325 15.37634,77.33322 94.51355,44.96818 142.54337,27.92312 41.25251,-19.85852 74.71211,13.81099 47.15338,51.488 -12.81622,29.9902 -14.46113,63.21674 24.32325,48.65688 16.10743,21.83905 43.9754,12.57457 67.78603,19.03461 38.31421,13.47166 77.19382,31.81122 97.70597,68.50239 21.293609,36.98933 44.436529,77.64473 46.371,120.536 11.39275,21.95642 17.62657,54.06755 22.786875,80.10637 -1.319042,30.31478 9.1770622,62.72516 8.520125,94.89763 C -3.0364735,-66.030256 2.7957668,-33.727398 0,0 Z"
style="display:inline;fill:#000015;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.00024;stroke-dasharray:none;paint-order:stroke markers fill"
clip-path="url(#clipPath4)"
transform="matrix(0.51651973,0,0,-0.51923866,749.95318,497.299)"
inkscape:label="galaxy">
<title
id="title163">galaxy</title>
</path>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Wars - Wild Space</title>
<link rel="stylesheet" href="./style.css" />
<link rel="icon" type="image/png" href="./graphism/favicon.png" />
</head>
<body>
<!-- Auth Modal ──────────────────────────────────────────────────────────── -->
@@ -35,10 +36,6 @@
<label>Nom d'utilisateur</label>
<input type="text" id="regUsername" autocomplete="username" required />
</div>
<div class="authField">
<label>Adresse courriel</label>
<input type="email" id="regEmail" autocomplete="email" required />
</div>
<div class="authField">
<label>Mot de passe <span class="authHint">(6 caractères min.)</span></label>
<input type="password" id="regPassword" autocomplete="new-password" required />
@@ -48,14 +45,23 @@
<div class="authTeamChoice">
<label class="authTeamOption">
<input type="radio" name="regTeam" value="blue" required />
<span class="authTeamBadge authTeamBadge--blue">Résistance</span>
<span class="authTeamBadge authTeamBadge--blue">
<img src="./graphism/logo_resistance.svg" class="authTeamLogo" alt="" />
Résistance
</span>
<span class="authTeamCount authTeamCount--blue" id="regCountBlue">… joueurs</span>
</label>
<label class="authTeamOption">
<input type="radio" name="regTeam" value="red" />
<span class="authTeamBadge authTeamBadge--red">Premier ordre</span>
<span class="authTeamBadge authTeamBadge--red">
Premier ordre
<img src="./graphism/logo_first_order.svg" class="authTeamLogo" alt="" />
</span>
<span class="authTeamCount authTeamCount--red" id="regCountRed">… joueurs</span>
</label>
</div>
</div>
<p class="authNotice">⚠ En cas d'oubli du mot de passe, aucune récupération n'est possible.</p>
<div class="authError hidden" id="registerError"></div>
<button type="submit" class="authSubmit">Créer le compte</button>
</form>
@@ -72,22 +78,21 @@
<button type="button" id="closeMenuBtn" class="closeMenuBtn" aria-label="Close menu"></button>
<div class="infoSection infoSection--title">
<div class="h1">Star Wars - Wild Space</div>
<img src="./graphism/Version_03.png" alt="Star Wars - Wild Space" class="titleBanderol" />
<div class="sub">Explorez les Régions Inconnues pour faire triompher votre camp !</div>
</div>
<!-- Team score display -->
<div class="scoreBoard" id="scoreBoard">
<img src="./graphism/logo_resistance.svg" alt="Resistance" class="team-logo" />
<div class="teamLogoWrap">
<img src="./graphism/logo_resistance.svg" alt="Resistance" class="team-logo" />
<span class="teamPlayerCount teamPlayerCount--blue" id="activeCountBlue">0 joueur</span>
</div>
<div class="scoreBoardContent">
<div class="scoreBoardRow">
<div class="scoreTeam scoreTeam--blue">
<span class="scoreTeamName">Résistance</span>
<div class="scoreStats">
<div class="scoreStat">
<span class="scoreStatVal scoreValue" id="scoreBlue">0</span>
<span class="scoreStatLabel">Tuiles</span>
</div>
<div class="scoreStat">
<span class="scoreStatVal scoreVP" id="vpBlue">0</span>
<span class="scoreStatLabel">Points</span>
@@ -102,10 +107,6 @@
<span class="scoreStatVal scoreVP" id="vpRed">0</span>
<span class="scoreStatLabel">Points</span>
</div>
<div class="scoreStat">
<span class="scoreStatVal scoreValue" id="scoreRed">0</span>
<span class="scoreStatLabel">Tuiles</span>
</div>
</div>
</div>
</div>
@@ -133,7 +134,10 @@
</div>
</div>
</div>
<img src="./graphism/logo_first_order.svg" alt="First Order" class="team-logo" />
<div class="teamLogoWrap">
<img src="./graphism/logo_first_order.svg" alt="First Order" class="team-logo" />
<span class="teamPlayerCount teamPlayerCount--red" id="activeCountRed">0 joueur</span>
</div>
</div>
<!-- Info rows -->
@@ -146,14 +150,19 @@
</span>
</div>
<div class="infoRow" id="countdownWrap" aria-live="polite">
<span class="infoKey countdownLabel">Prochain clic</span>
<span class="infoKey countdownLabel">Actions restantes</span>
<span class="infoVal countdownVal">
<span id="countdown" class="countdown">0</span>
<span class="countdownUnit">s</span>
</span>
</div>
<div class="infoRow" aria-live="polite">
<span class="infoKey countdownLabel">Actions équipe restantes</span>
<span class="infoVal countdownVal">
<span id="teamActionsRemaining" class="countdown"></span>
</span>
</div>
<div class="infoRow">
<span class="infoKey muted">Délai entre deux clics</span>
<span class="infoKey muted">Actions par jour</span>
<code class="infoVal" id="cooldownConfig"></code>
</div>
<div class="infoRow">
@@ -178,6 +187,7 @@
<details class="panel panelCollapsible" id="planetStatsDetails" open>
<summary class="panelTitle panelTitleSummary">🪐 Statistiques Planétaires</summary>
<pre id="details" class="details details--hidden">Les stats sont vides sauf à cliquer sur une tuile exploitable.</pre>
<div id="captorInfo" class="captorInfo hidden"></div>
</details>
<!-- Resources overview (collapsible) -->
@@ -204,7 +214,7 @@
<span class="elemBonusLabel">Premier Ordre</span>
</span>
<span class="elemBonusEffective">
<span class="elemBonusDetailLabel">Recharge :</span>
<span class="elemBonusDetailLabel">Quota effectif/j :</span>
<span class="elemBonusDetailVal" id="effectiveCooldown"></span>
</span>
</div>
@@ -213,14 +223,107 @@
</div>
</details>
<!-- Military power (collapsible) -->
<details class="panel panelCollapsible">
<summary class="panelTitle panelTitleSummary">⚔️ Puissance Militaire</summary>
<div class="milPowerTotals">
<span class="milPowerTeam milPowerTeam--blue">
<span class="milPowerLabel">Résistance</span>
<span class="milPowerVal" id="milPowerBlue">0.0</span>
</span>
<span class="milPowerSep"></span>
<span class="milPowerTeam milPowerTeam--red">
<span class="milPowerVal" id="milPowerRed">0.0</span>
<span class="milPowerLabel">Premier Ordre</span>
</span>
</div>
<div id="militaryTableBody" class="econTableWrap">
<p class="econEmpty">Chargement…</p>
</div>
</details>
<!-- Game rules -->
<details class="panel panelCollapsible">
<summary class="panelTitle panelTitleSummary">📜 Règles du jeu</summary>
<div class="rulesContent">
<p><strong>Points de Victoire</strong><br/>
À la fin de chaque période (rotation de la carte), l'équipe ayant le <em>score économique</em> le plus élevé remporte <strong>1 Point de Victoire</strong>. Ces points sont cumulatifs d'une manche à l'autre.</p>
<p><strong>Tableau des scores</strong><br/>
Sous le tableau des scores, plusieurs indicateurs sont affichés :</p>
<ul>
<li><strong>Revenu/s</strong> — crédits générés chaque seconde grâce aux ressources naturelles des planètes découvertes.</li>
<li><strong>Score économique</strong> — total cumulé des crédits gagnés au fil du temps. Mis à jour toutes les 5 secondes.</li>
</ul>
<p><strong>💰 Ressources</strong><br/>
En explorant la galaxie, vous révélez des tuiles qui peuvent contenir des planètes. Chaque planète possède des ressources naturelles (minerais, bois, pétrole, etc.) dont la valeur contribue au revenu de votre équipe.</p>
<p><strong>⚡ Bonus d'exploration</strong><br/>
Les planètes produisent des éléments (matières premières, carburant, nourriture, science…) qui offrent un bonus cumulatif. Ce bonus augmente le pourcentage d'actions disponibles pour vous.</p>
<p><strong>⚔️ Puissance militaire</strong><br/>
La population des planètes conquises fournit de la puissance militaire. Cette puissance augmente le quota d'actions d'équipe pour capturer des planètes.</p>
</div>
</details>
<!-- Credits -->
<details class="panel panelCollapsible">
<summary class="panelTitle panelTitleSummary">🏷️ Crédits</summary>
<div class="rulesContent">
<p><strong>Dépôt Git</strong><br/>
<a href="https://git.gbmm-holocron.eu/gauvain/star-wars-wild-space" target="_blank" rel="noopener">
git.gbmm-holocron.eu/gauvain/star-wars-wild-space
</a>
</p>
<p>Le projet "Star Wars: Wild Space" a été pensé et pitché en 2021 par Mya TELLIS et Harlon ASTELLAN (moi), pseudonymes hérités du forum de jeu de rôle littéraire "Star Wars - Online Roleplay" anciennement "Star Wars Old Revolution". Le projet n'ayant pas abouti faute de développeur disponible, il a été abandonné, mais pas en esprit. Faute de temps, de motivation et d'argent pour coder/le faire coder, j'ai décidé de mettre à profit l'agentique IA pour faire naître ce jeu sans prétention.</p>
<p>Des morceaux ne sont pas issus de l'IA, notamment la section de génération des planètes, réalisée à la main pour m'entraîner d'abord sur Python puis Javascript. Mais les morceaux ont été "révisés" par l'IA pour l'intégrer à une API, aussi on peut considérer que 100% du contenu ici est généré par Claude Sonnet 4.6, à 1% près. Aussi si vous êtes le genre à chouiner "gnagnagna l'IA le voooool des artiiiiistes" etc, sachez que sans l'IA il n'y aurait rien eu, l'outil existe, autant vous y faire, et pour ce projet personne n'a été remplacé, et personne ne vous force à y jouer. Allez révolutionner ailleurs.</p>
<p>Il n'y a pas (encore ?) de gestion de compte par courriel avec mot de passe oublié etc parce que l'implémentation est plus complexe. Pour l'instant, c'est suffisamment tranquille pour s'en passer. Au besoin refaites un compte.</p>
</div>
</details>
</aside>
<!-- Player list popup (shown on click of joueur count) -->
<div id="playerListPopup" class="playerListPopup hidden" role="tooltip">
<div id="playerListContent"></div>
</div>
<!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── -->
<main class="galaxyMain">
<!-- Mobile burger button -->
<button type="button" id="burgerBtn" class="burgerBtn" aria-label="Open menu"></button>
<canvas id="canvas" width="1000" height="1000"></canvas>
<div id="hint" class="hint">Cliquez sur une tuile. Les stats seront vides à moins de cliquer.</div>
<!-- Military attack confirmation modal -->
<div class="attackOverlay hidden" id="attackOverlay">
<div class="attackModal">
<div class="attackModal__icon">⚔️</div>
<div class="attackModal__title">Attaque Militaire</div>
<div class="attackModal__body" id="attackModalBody"></div>
<div class="attackModal__actions">
<button type="button" class="attackModal__btn attackModal__btn--cancel" id="attackModalNo">Annuler</button>
<button type="button" class="attackModal__btn attackModal__btn--confirm" id="attackModalYes">Attaquer !</button>
</div>
</div>
</div>
<!-- Planet capture confirmation modal -->
<div class="attackOverlay hidden" id="captureOverlay">
<div class="attackModal">
<div class="attackModal__icon">🏴</div>
<div class="attackModal__title">Capturer la Planète</div>
<div class="attackModal__body" id="captureModalBody"></div>
<div class="attackModal__actions">
<button type="button" class="attackModal__btn attackModal__btn--cancel" id="captureModalNo">Annuler</button>
<button type="button" class="attackModal__btn attackModal__btn--confirm" id="captureModalYes">Capturer !</button>
</div>
</div>
</div>
</main>
</div>

View File

@@ -10,15 +10,11 @@ export async function apiFetchConfig(team) {
return res.json();
}
export async function apiFetchScores() {
const res = await fetch("/api/scores");
if (!res.ok) throw new Error("scores_fetch_failed");
return res.json();
}
/** Returns the raw Response so the caller can inspect status codes (410, etc.). */
export async function apiFetchGrid(seed) {
return fetch(`/api/grid/${encodeURIComponent(seed)}`);
const token = localStorage.getItem("authToken");
const headers = token ? { Authorization: `Bearer ${token}` } : {};
return fetch(`/api/grid/${encodeURIComponent(seed)}`, { headers });
}
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */
@@ -42,11 +38,11 @@ export async function apiLogin(username, password) {
});
}
export async function apiRegister(username, email, password, team) {
export async function apiRegister(username, password, team) {
return fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password, team }),
body: JSON.stringify({ username, password, team }),
});
}
@@ -99,3 +95,59 @@ export async function apiFetchVictoryPoints() {
if (!res.ok) throw new Error("vp_fetch_failed");
return res.json();
}
export async function apiFetchPlayerCounts() {
const res = await fetch("/api/auth/player-counts");
if (!res.ok) throw new Error("player_counts_fetch_failed");
return res.json();
}
export async function apiFetchActivePlayers() {
const res = await fetch("/api/active-players");
if (!res.ok) throw new Error("active_players_fetch_failed");
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");
return res.json();
}
export async function apiMilitaryAttack(seed, x, y) {
const token = localStorage.getItem("authToken");
const res = await fetch("/api/military/attack", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ seed, x, y }),
});
return res; // caller inspects status
}
export async function apiFetchCellAttackCount(x, y) {
const res = await fetch(`/api/cell/attacks?x=${x}&y=${y}`);
if (!res.ok) throw new Error("cell_attacks_fetch_failed");
return res.json();
}
/** Returns the raw Response so the caller can inspect status codes. */
export async function apiCaptureCell(seed, x, y) {
const token = localStorage.getItem("authToken");
return fetch("/api/cell/capture", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ seed, x, y }),
});
}

View File

@@ -1,4 +1,4 @@
import { apiLogin, apiRegister, apiGetMe } from "./api.js";
import { apiLogin, apiRegister, apiGetMe, apiFetchPlayerCounts } from "./api.js";
import { setCurrentTeam, refreshFromServer } from "./game.js";
// ── DOM refs ──────────────────────────────────────────────────────────────────
@@ -12,9 +12,10 @@ const loginUsernameEl = document.getElementById("loginUsername");
const loginPasswordEl = document.getElementById("loginPassword");
const loginErrorEl = document.getElementById("loginError");
const regUsernameEl = document.getElementById("regUsername");
const regEmailEl = document.getElementById("regEmail");
const regPasswordEl = document.getElementById("regPassword");
const registerErrorEl = document.getElementById("registerError");
const regCountBlueEl = document.getElementById("regCountBlue");
const regCountRedEl = document.getElementById("regCountRed");
const userDisplayEl = document.getElementById("userDisplay");
const logoutBtn = document.getElementById("logoutBtn");
@@ -38,7 +39,8 @@ export function applyUser(user, token) {
authToken = token;
localStorage.setItem("authToken", token);
setCurrentTeam(user.team);
userDisplayEl.textContent = `${user.username} [${user.team}]`;
const teamColor = user.team === "blue" ? "rgba(90,200,255,0.9)" : "rgba(220,75,85,0.9)";
userDisplayEl.innerHTML = `<span style="color:${teamColor}">${user.username}</span>`;
logoutBtn.classList.remove("hidden");
}
@@ -68,6 +70,15 @@ export async function tryRestoreSession() {
// ── Tab switching ─────────────────────────────────────────────────────────────
async function loadRegisterCounts() {
try {
const counts = await apiFetchPlayerCounts();
const fmt = (n) => `${n} joueur${n > 1 ? "s" : ""}`;
regCountBlueEl.textContent = fmt(counts.blue ?? 0);
regCountRedEl.textContent = fmt(counts.red ?? 0);
} catch { /* silently ignore */ }
}
tabLogin.addEventListener("click", () => {
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
@@ -82,6 +93,7 @@ tabRegister.addEventListener("click", () => {
registerForm.classList.remove("hidden");
loginForm.classList.add("hidden");
clearError(registerErrorEl);
loadRegisterCounts();
});
// ── Login form ────────────────────────────────────────────────────────────────
@@ -114,12 +126,11 @@ registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearError(registerErrorEl);
const username = regUsernameEl.value.trim();
const email = regEmailEl.value.trim();
const password = regPasswordEl.value;
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
if (!teamInput) { showError(registerErrorEl, "Please choose a team."); return; }
try {
const res = await apiRegister(username, email, password, teamInput.value);
const res = await apiRegister(username, password, teamInput.value);
const data = await res.json();
if (!res.ok) {
const msgs = {

View File

@@ -1,4 +1,4 @@
import { resources, elements } from "./planetEconomy.js";
import { resources, elements, population } from "./planetEconomy.js";
// ── Sort state (resources) ────────────────────────────────────────────────────
@@ -60,7 +60,7 @@ export function computeTeamIncome(team, cells, resourceWorth) {
let total = 0;
for (const [, meta] of cells) {
if (meta.discoveredBy !== team) continue;
if (meta.controlledBy !== team) continue;
if (!meta.hasPlanet || !meta.planet) continue;
const { naturalResources } = meta.planet;
if (!naturalResources) continue;
@@ -102,7 +102,7 @@ const ELEMENT_LABEL_TO_KEY = Object.fromEntries(
export function computeTeamElementBonus(team, cells, elementWorth) {
let bonus = 0;
for (const [, meta] of cells) {
if (meta.discoveredBy !== team) continue;
if (meta.controlledBy !== team) continue;
if (!meta.hasPlanet || !meta.planet) continue;
const { production } = meta.planet;
if (!production) continue;
@@ -130,7 +130,7 @@ export function computeTeamElementBonusDetailed(team, cells, elementWorth) {
const byElement = new Map();
let total = 0;
for (const [, meta] of cells) {
if (meta.discoveredBy !== team) continue;
if (meta.controlledBy !== team) continue;
if (!meta.hasPlanet || !meta.planet) continue;
const { production } = meta.planet;
if (!production) continue;
@@ -265,3 +265,109 @@ export function renderElementBonusTable(elementWorth, teamByElement) {
<tbody>${tableRows}</tbody>
</table>`;
}
// ── Military power ────────────────────────────────────────────────────────────
/** Reverse map: French population label → config key (e.g. "Humains" → "humans") */
const POP_LABEL_TO_KEY = new Map();
for (const [, subgroup] of Object.entries(population)) {
for (const [key, label] of Object.entries(subgroup)) {
POP_LABEL_TO_KEY.set(label, key);
}
}
/** Sort state for military table: 0=Type, 1=% Mil., 2=Soldats */
let _milSortCol = 2;
let _milSortDir = "desc";
export function setMilSort(col, dir) { _milSortCol = col; _milSortDir = dir; }
export function getMilSort() { return { col: _milSortCol, dir: _milSortDir }; }
/**
* Compute military power breakdown for a team based on planet populations.
* military = population.billions * (militaryPower[typeKey] / 100)
* Result is in billions; display as millions (× 1000).
*
* @param {string} team
* @param {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} cells
* @param {object} militaryPower - { humans: 0.01, near: 0.005, aliens: 0.001 }
* @returns {{ total: number, byType: Map<string, number> }}
* byType keys are French population label strings, values are military power in billions
*/
export function computeTeamMilitaryDetailed(team, cells, militaryPower) {
const byType = new Map();
let total = 0;
for (const [, meta] of cells) {
if (meta.controlledBy !== team) continue;
if (!meta.hasPlanet || !meta.planet) continue;
const pop = meta.planet.population;
if (!pop) continue;
const key = POP_LABEL_TO_KEY.get(pop.majority);
if (!key) continue;
const pct = militaryPower?.[key] ?? 0;
if (pct === 0) continue;
const mil = pop.billions * pct / 100;
byType.set(pop.majority, (byType.get(pop.majority) ?? 0) + mil);
total += mil;
}
return { total, byType };
}
/**
* Renders the military power breakdown table.
*
* @param {object} militaryPower - { humans: 0.01, near: 0.005, aliens: 0.001 }
* @param {Map<string, number>} teamByType - military (billions) per population label
* @returns {string} HTML string
*/
export function renderMilitaryTable(militaryPower, teamByType) {
const rows = [];
for (const [groupKey, subgroup] of Object.entries(population)) {
if (groupKey === "creatures") continue;
for (const [key, label] of Object.entries(subgroup)) {
const pct = militaryPower?.[key] ?? 0;
const mil = teamByType?.get(label) ?? 0;
const milStr = mil > 0 ? `${(mil * 1000).toFixed(1)}` : "—";
rows.push({ label, pct, mil, milStr });
}
}
const mult = _milSortDir === "asc" ? 1 : -1;
rows.sort((a, b) => {
if (_milSortCol === 0) return mult * a.label.localeCompare(b.label, "fr");
if (_milSortCol === 1) return mult * (a.pct - b.pct);
if (_milSortCol === 2) return mult * (a.mil - b.mil);
return b.mil - a.mil || b.pct - a.pct;
});
if (rows.every(r => r.mil === 0)) {
return `<p class="econEmpty">Aucune tuile conquise avec population militarisable.</p>`;
}
const tableRows = rows
.map(({ label, pct, mil, milStr }) => {
const milClass = mil > 0 ? " econ-income--positive" : "";
return `<tr>
<td class="econ-label">${label}</td>
<td class="econ-worth">${pct}%</td>
<td class="econ-income${milClass}">${milStr}</td>
</tr>`;
})
.join("");
const thLabels = ["Type", "% Mil.", "Soldats"];
const headers = thLabels
.map((lbl, i) => {
const isActive = i === _milSortCol;
const indicator = isActive ? (_milSortDir === "asc" ? " ▲" : " ▼") : " ⇅";
const activeClass = isActive ? " econTh--active" : "";
return `<th class="econTh${activeClass}" data-mil-sort-col="${i}">${lbl}<span class="econSortIcon">${indicator}</span></th>`;
})
.join("");
return `<table class="econTable">
<thead><tr>${headers}</tr></thead>
<tbody>${tableRows}</tbody>
</table>`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,13 @@ import {
updateResetCountdown,
fetchConfig,
fetchGridForSeed,
fetchAndApplyScores,
fetchAndApplyActivePlayers,
updateEconomyDisplay,
loadEconScores,
loadVictoryPoints,
loadDbInfo,
loadElementBonus,
loadMilitaryDeductions,
refreshFromServer,
refreshGridDisplay,
loadPlayfieldMask,
@@ -53,9 +54,10 @@ const ECON_TICK_SECONDS = 5;
function scheduleScorePoll() {
clearTimeout(scorePollTimer);
scorePollTimer = window.setTimeout(async () => {
await fetchAndApplyScores();
await fetchAndApplyActivePlayers();
await loadEconScores();
await loadElementBonus();
await loadMilitaryDeductions();
scheduleScorePoll();
}, ECON_TICK_SECONDS * 1_000);
}
@@ -100,11 +102,12 @@ async function boot() {
try {
await fetchConfig();
await fetchGridForSeed(seedStr);
await fetchAndApplyScores();
await fetchAndApplyActivePlayers();
await loadEconScores();
await loadVictoryPoints();
await loadDbInfo();
await loadElementBonus();
await loadMilitaryDeductions();
updateEconomyDisplay();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";

View File

@@ -134,16 +134,23 @@ body {
}
.authTeamBadge {
display: block;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.1);
text-align: center;
font-weight: 700;
font-size: 13px;
transition: border-color 0.15s, background 0.15s;
}
.authTeamLogo {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.authTeamBadge--blue {
color: rgba(90, 200, 255, 0.9);
}
@@ -175,6 +182,14 @@ body {
display: none;
}
.authNotice {
margin: 0;
font-size: 11px;
color: rgba(255, 193, 113, 0.85);
text-align: center;
opacity: 0.8;
}
.authSubmit {
margin-top: 4px;
padding: 12px;
@@ -192,6 +207,18 @@ body {
background: rgba(113, 199, 255, 0.28);
}
.authTeamCount {
display: block;
text-align: center;
font-size: 10px;
font-weight: 600;
margin-top: 4px;
opacity: 0.7;
}
.authTeamCount--blue { color: rgba(90, 200, 255, 0.9); }
.authTeamCount--red { color: rgba(220, 75, 85, 0.9); }
/* ── Score board ──────────────────────────────────────────────────────────── */
@@ -239,6 +266,26 @@ body {
flex-shrink: 0;
}
.teamLogoWrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.teamPlayerCount {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.75;
white-space: nowrap;
}
.teamPlayerCount--blue { color: rgba(90, 200, 255, 0.9); }
.teamPlayerCount--red { color: rgba(220, 75, 85, 0.9); }
.scoreTeam {
display: flex;
flex-direction: column;
@@ -305,8 +352,8 @@ body {
}
.scoreTeam--blue .scoreVP {
color: rgba(130, 230, 130, 1);
text-shadow: 0 0 16px rgba(90, 200, 130, 0.35);
color: rgba(90, 200, 255, 1);
text-shadow: 0 0 16px rgba(90, 200, 255, 0.4);
}
.scoreTeam--red .scoreValue {
@@ -315,8 +362,8 @@ body {
}
.scoreTeam--red .scoreVP {
color: rgba(230, 150, 80, 1);
text-shadow: 0 0 16px rgba(220, 130, 60, 0.35);
color: rgba(220, 75, 85, 1);
text-shadow: 0 0 16px rgba(220, 75, 85, 0.4);
}
.scoreSep {
@@ -333,7 +380,7 @@ body {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: center;
justify-content: flex-start;
min-height: 100vh;
}
@@ -387,6 +434,14 @@ body {
letter-spacing: 0.2px;
}
.titleBanderol {
display: block;
max-height: 100px;
width: auto;
max-width: 100%;
object-fit: contain;
}
.infoSection--title .sub {
font-size: 11px;
opacity: 0.65;
@@ -894,6 +949,167 @@ button:hover {
color: rgba(255, 220, 100, 0.9);
}
/* ── Military power section ───────────────────────────────────────────────── */
.milPowerTotals {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 8px 12px 4px;
font-family: "Courier New", Courier, monospace;
font-size: 12px;
flex-wrap: wrap;
row-gap: 4px;
}
.milPowerTeam {
display: flex;
align-items: center;
gap: 5px;
flex: 1;
}
.milPowerTeam--blue { justify-content: flex-end; }
.milPowerTeam--red { justify-content: flex-start; }
.milPowerTeam--blue .milPowerLabel { color: rgba(90, 200, 255, 0.75); font-size: 10px; }
.milPowerTeam--blue .milPowerVal { color: rgba(90, 200, 255, 1); font-weight: 800; font-size: 13px; }
.milPowerTeam--red .milPowerLabel { color: rgba(220, 75, 85, 0.75); font-size: 10px; }
.milPowerTeam--red .milPowerVal { color: rgba(220, 75, 85, 1); font-weight: 800; font-size: 13px; }
.milPowerUnit {
font-size: 10px;
opacity: 0.6;
}
.milPowerSep {
opacity: 0.3;
flex: 0 0 auto;
margin: 0 6px;
}
/* ── Rules / Credits content ──────────────────────────────────────────────── */
.rulesContent {
padding: 8px 12px 12px;
font-size: 12px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.78);
}
.rulesContent p {
margin: 0 0 10px;
}
.rulesContent ul {
margin: 4px 0 10px 16px;
padding: 0;
}
.rulesContent li {
margin-bottom: 4px;
}
.rulesContent a {
color: rgba(90, 200, 255, 0.9);
text-decoration: none;
}
.rulesContent a:hover {
text-decoration: underline;
}
/* ── Military attack modal ────────────────────────────────────────────────── */
.attackOverlay {
position: absolute;
inset: 0;
z-index: 20;
background: rgba(5, 8, 18, 0.72);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
}
.attackOverlay.hidden {
display: none;
}
.attackModal {
width: 340px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(15, 22, 42, 0.97);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
padding: 28px 28px 24px;
text-align: center;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
}
.attackModal__icon {
font-size: 36px;
line-height: 1;
margin-bottom: 10px;
}
.attackModal__title {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
color: #e9eef6;
margin-bottom: 14px;
}
.attackModal__body {
font-size: 13px;
color: rgba(233, 238, 246, 0.8);
line-height: 1.6;
white-space: pre-line;
margin-bottom: 22px;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
padding: 12px 14px;
text-align: left;
}
.attackModal__actions {
display: flex;
gap: 10px;
justify-content: center;
}
.attackModal__btn {
flex: 1;
padding: 9px 0;
border-radius: 10px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.attackModal__btn--cancel {
background: rgba(255, 255, 255, 0.08);
color: rgba(233, 238, 246, 0.75);
}
.attackModal__btn--cancel:hover {
filter: brightness(1.3);
}
.attackModal__btn--confirm {
background: rgba(220, 75, 85, 0.85);
color: #fff;
}
.attackModal__btn--confirm:hover {
filter: brightness(1.2);
}
/* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */
.galaxyMain {
@@ -914,6 +1130,7 @@ canvas {
width: 100%;
height: 100%;
image-rendering: pixelated;
touch-action: none; /* prevent browser pinch-zoom on canvas */
}
/* ── Burger button (hidden on desktop) ────────────────────────────────────── */
@@ -1015,3 +1232,67 @@ canvas {
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;
}

View File

@@ -1,4 +1,5 @@
import express from "express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import authRouter from "./routes/auth.js";
@@ -8,6 +9,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(__dirname, "..", "public");
const app = express();
app.use(cors({ origin: process.env.CORS_ORIGIN ?? "*" }));
app.use(express.json());
app.use(express.static(publicDir));

View File

@@ -7,14 +7,16 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_FILE_PATH =
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json");
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object }} */
/** @type {{ dailyActionQuota: number, teamActionQuota: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
let cached = {
clickCooldownSeconds: 5,
dailyActionQuota: 100,
teamActionQuota: 100,
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
elementWorth: {},
resourceWorth: { common: {}, rare: {} },
militaryPower: {},
};
let lastMtimeMs = 0;
@@ -37,8 +39,11 @@ export function loadConfigFile() {
}
const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8");
const j = JSON.parse(raw);
if (typeof j.clickCooldownSeconds === "number" && j.clickCooldownSeconds >= 0) {
cached.clickCooldownSeconds = j.clickCooldownSeconds;
if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) {
cached.dailyActionQuota = Math.floor(j.dailyActionQuota);
}
if (typeof j.teamActionQuota === "number" && j.teamActionQuota >= 1) {
cached.teamActionQuota = Math.floor(j.teamActionQuota);
}
if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) {
cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds;
@@ -53,8 +58,9 @@ export function loadConfigFile() {
}
if (j.resourceWorth && typeof j.resourceWorth === "object") {
cached.resourceWorth = j.resourceWorth;
}
lastMtimeMs = st.mtimeMs;
} if (j.militaryPower && typeof j.militaryPower === 'object') {
cached.militaryPower = j.militaryPower;
} lastMtimeMs = st.mtimeMs;
} catch (e) {
if (e.code === "ENOENT") {
lastMtimeMs = 0;

View File

@@ -1,6 +1,7 @@
import { pool } from "./pools.js";
import { loadConfigFile, getConfig } from "../configLoader.js";
import { computeWorldSeedState } from "../worldSeed.js";
import { nextNoonUtc, resetAllUserActions } from "./usersDb.js";
let lastSeedSlot = null;
@@ -54,6 +55,26 @@ export async function initGameSchema() {
PRIMARY KEY (world_seed, team)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS team_military_deductions (
world_seed TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
deducted DOUBLE PRECISION NOT NULL DEFAULT 0,
PRIMARY KEY (world_seed, team)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS cell_attack_log (
id SERIAL PRIMARY KEY,
world_seed TEXT NOT NULL,
x SMALLINT NOT NULL,
y SMALLINT NOT NULL,
attacking_team TEXT NOT NULL CHECK (attacking_team IN ('blue', 'red')),
attacked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cell_attack_log_seed_xy
ON cell_attack_log (world_seed, x, y);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS db_metadata (
id SERIAL PRIMARY KEY,
@@ -86,6 +107,43 @@ export async function initGameSchema() {
END $$;
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS team_action_quota (
team TEXT PRIMARY KEY CHECK (team IN ('blue', 'red')),
actions_remaining INTEGER NOT NULL DEFAULT 0,
quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00'
);
`);
// ── Per-team cell visibility (who has revealed what) ──────────────────────
await pool.query(`
CREATE TABLE IF NOT EXISTS team_cell_visibility (
world_seed TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
x SMALLINT NOT NULL,
y SMALLINT NOT NULL,
PRIMARY KEY (world_seed, team, x, y)
);
CREATE INDEX IF NOT EXISTS idx_tcv_seed_team ON team_cell_visibility (world_seed, team);
`);
// ── Make discovered_by nullable (NULL = neutral, uncaptured) ─────────────
await pool.query(`
ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP NOT NULL;
ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP DEFAULT;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'grid_cells_discovered_by_check'
) THEN
ALTER TABLE grid_cells DROP CONSTRAINT grid_cells_discovered_by_check;
END IF;
END $$;
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 ──────────────────────────────────────────────────────────
@@ -124,6 +182,21 @@ export async function ensureSeedEpoch() {
await pool.query("DELETE FROM user_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_element_bonus WHERE world_seed != $1", [worldSeed]);
await pool.query("DELETE FROM team_military_deductions WHERE world_seed != $1", [worldSeed]);
await pool.query("DELETE FROM cell_attack_log WHERE world_seed != $1", [worldSeed]);
await pool.query("DELETE FROM team_cell_visibility WHERE world_seed != $1", [worldSeed]);
// Reset both team and user quotas to server defaults (no bonuses) for the new period
const cfg = getConfig();
const nextNoon = nextNoonUtc().toISOString();
await pool.query(
`INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at)
VALUES ('blue', $1, $2), ('red', $1, $2)
ON CONFLICT (team) DO UPDATE
SET actions_remaining = $1,
quota_reset_at = $2`,
[cfg.teamActionQuota, nextNoon]
);
await resetAllUserActions(cfg.dailyActionQuota, nextNoon);
console.log(`[world] Slot ${lastSeedSlot}${seedSlot}; grid wiped, old cooldowns cleared.`);
lastSeedSlot = seedSlot;
}
@@ -134,27 +207,62 @@ 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]
);
return rows;
}
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson, team) {
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson) {
const { rows } = await pool.query(
`INSERT INTO grid_cells (world_seed, x, y, exploitable, has_planet, planet_json, discovered_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
VALUES ($1, $2, $3, $4, $5, $6, NULL)
ON CONFLICT (world_seed, x, y) DO NOTHING
RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`,
[seed, x, y, exploitable, hasPlanet, planetJson, team]
[seed, x, y, exploitable, hasPlanet, planetJson]
);
return rows[0] ?? null;
}
// ── Team cell visibility ──────────────────────────────────────────────────────
/** Returns true if team has already revealed this cell. */
export async function checkTeamVisibility(worldSeed, team, x, y) {
const { rows } = await pool.query(
`SELECT 1 FROM team_cell_visibility WHERE world_seed = $1 AND team = $2 AND x = $3 AND y = $4`,
[worldSeed, team, x, y]
);
return rows.length > 0;
}
/** Inserts a visibility record. Returns true if newly inserted (first reveal). */
export async function insertTeamVisibility(worldSeed, team, x, y) {
const { rowCount } = await pool.query(
`INSERT INTO team_cell_visibility (world_seed, team, x, y)
VALUES ($1, $2, $3, $4)
ON CONFLICT (world_seed, team, x, y) DO NOTHING`,
[worldSeed, team, x, y]
);
return rowCount > 0;
}
/** 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, 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
WHERE gc.world_seed = $1 AND tcv.team = $2`,
[worldSeed, team]
);
return rows;
}
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]
);
@@ -267,9 +375,133 @@ export async function getVictoryPoints() {
export async function getScores(worldSeed) {
const { rows } = await pool.query(
`SELECT discovered_by, COUNT(*) AS cnt
FROM grid_cells WHERE world_seed = $1
FROM grid_cells WHERE world_seed = $1 AND discovered_by IS NOT NULL
GROUP BY discovered_by`,
[worldSeed]
);
return rows;
}
// ── Active player counts (players who have played in the current epoch) ───────
export async function getActivePlayerCounts(worldSeed) {
const { rows } = await pool.query(
`SELECT team, COUNT(DISTINCT user_id)::int AS count
FROM user_cooldowns WHERE world_seed = $1
GROUP BY team`,
[worldSeed]
);
const result = { blue: 0, red: 0 };
for (const row of rows) result[row.team] = row.count;
return result;
}
// ── Military deductions ───────────────────────────────────────────────────────
export async function getMilitaryDeductions(worldSeed) {
const { rows } = await pool.query(
`SELECT team, deducted FROM team_military_deductions WHERE world_seed = $1`,
[worldSeed]
);
const result = { blue: 0, red: 0 };
for (const row of rows) result[row.team] = Number(row.deducted);
return result;
}
export async function addMilitaryDeduction(worldSeed, team, amount) {
await pool.query(
`INSERT INTO team_military_deductions (world_seed, team, deducted)
VALUES ($1, $2, $3)
ON CONFLICT (world_seed, team) DO UPDATE
SET deducted = team_military_deductions.deducted + EXCLUDED.deducted`,
[worldSeed, team, amount]
);
}
// ── Cell attack log ───────────────────────────────────────────────────────────
export async function recordCellAttack(worldSeed, x, y, attackingTeam) {
await pool.query(
`INSERT INTO cell_attack_log (world_seed, x, y, attacking_team) VALUES ($1, $2, $3, $4)`,
[worldSeed, x, y, attackingTeam]
);
}
export async function getCellAttackCount(worldSeed, x, y) {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS cnt FROM cell_attack_log WHERE world_seed = $1 AND x = $2 AND y = $3`,
[worldSeed, x, y]
);
return rows[0]?.cnt ?? 0;
}
export async function setTileOwner(worldSeed, x, y, team, capturedBy = null) {
await pool.query(
`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) {
const { rows } = await pool.query(
`SELECT actions_remaining, quota_reset_at FROM team_action_quota WHERE team = $1`,
[team]
);
return rows[0] ?? null;
}
export async function resetTeamActions(team, actionsRemaining, quotaResetAt) {
await pool.query(
`INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at)
VALUES ($1, $2, $3)
ON CONFLICT (team) DO UPDATE
SET actions_remaining = $2,
quota_reset_at = $3`,
[team, actionsRemaining, quotaResetAt]
);
}
/**
* Atomically decrements team actions_remaining by 1 if > 0.
* Returns the updated row, or null if already 0.
*/
export async function decrementTeamActions(team) {
const { rows } = await pool.query(
`UPDATE team_action_quota
SET actions_remaining = actions_remaining - 1
WHERE team = $1 AND actions_remaining > 0
RETURNING actions_remaining`,
[team]
);
return rows[0] ?? null;
}
/**
* Atomically decrements team actions_remaining by `amount` if sufficient.
* Returns the updated row, or null if not enough actions.
*/
export async function decrementTeamActionsBy(team, amount) {
const { rows } = await pool.query(
`UPDATE team_action_quota
SET actions_remaining = actions_remaining - $2
WHERE team = $1 AND actions_remaining >= $2
RETURNING actions_remaining`,
[team, amount]
);
return rows[0] ?? null;
}

View File

@@ -1,10 +1,11 @@
import pg from "pg";
const DATABASE_URL =
process.env.DATABASE_URL ?? "postgres://game:game@localhost:5432/star_wars_grid";
if (!process.env.DATABASE_URL) {
throw new Error("[startup] DATABASE_URL environment variable is required but not set.");
}
if (!process.env.USERS_DATABASE_URL) {
throw new Error("[startup] USERS_DATABASE_URL environment variable is required but not set.");
}
const USERS_DATABASE_URL =
process.env.USERS_DATABASE_URL ?? "postgres://users:users@localhost:5433/star_wars_users";
export const pool = new pg.Pool({ connectionString: DATABASE_URL });
export const usersPool = new pg.Pool({ connectionString: USERS_DATABASE_URL });
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const usersPool = new pg.Pool({ connectionString: process.env.USERS_DATABASE_URL });

View File

@@ -7,30 +7,52 @@ export async function initUsersSchema() {
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await initUserActionQuotaSchema();
}
export async function initUserActionQuotaSchema() {
await usersPool.query(`
CREATE TABLE IF NOT EXISTS user_action_quota (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
actions_remaining INTEGER NOT NULL DEFAULT 0,
quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00'
);
`);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Returns the next noon (12:00:00) UTC after the current moment. */
export function nextNoonUtc() {
const now = new Date();
const noon = new Date(Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0, 0
));
if (noon <= now) noon.setUTCDate(noon.getUTCDate() + 1);
return noon;
}
// ── Queries ───────────────────────────────────────────────────────────────────
export async function createUser(username, email, passwordHash, team) {
export async function createUser(username, passwordHash, team) {
const { rows } = await usersPool.query(
`INSERT INTO users (username, email, password_hash, team)
VALUES ($1, $2, $3, $4)
RETURNING id, username, email, team, role`,
[username, email, passwordHash, team]
`INSERT INTO users (username, password_hash, team)
VALUES ($1, $2, $3)
RETURNING id, username, team, role`,
[username, passwordHash, team]
);
return rows[0];
}
export async function getUserByUsername(username) {
const { rows } = await usersPool.query(
`SELECT id, username, email, team, role, password_hash FROM users WHERE username = $1`,
`SELECT id, username, team, role, password_hash FROM users WHERE username = $1`,
[username]
);
return rows[0] ?? null;
@@ -38,8 +60,80 @@ export async function getUserByUsername(username) {
export async function getUserById(id) {
const { rows } = await usersPool.query(
`SELECT id, username, email, team, role FROM users WHERE id = $1`,
`SELECT id, username, team, role FROM users WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
export async function getTeamPlayerCounts() {
const { rows } = await usersPool.query(
`SELECT team, COUNT(*)::int AS count FROM users GROUP BY team`
);
const result = { blue: 0, red: 0 };
for (const row of rows) result[row.team] = row.count;
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. */
export async function getUserActionsRow(userId) {
const { rows } = await usersPool.query(
`SELECT actions_remaining, quota_reset_at FROM user_action_quota WHERE user_id = $1`,
[userId]
);
return rows[0] ?? null;
}
/** Insert or overwrite the quota row for a user. */
export async function resetUserActions(userId, actionsRemaining, quotaResetAt) {
await usersPool.query(
`INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET actions_remaining = $2,
quota_reset_at = $3`,
[userId, actionsRemaining, quotaResetAt]
);
}
/**
* Atomically decrements actions_remaining by 1 if > 0.
* Returns the updated row (with new actions_remaining), or null if already 0.
*/
export async function decrementUserActions(userId) {
const { rows } = await usersPool.query(
`UPDATE user_action_quota
SET actions_remaining = actions_remaining - 1
WHERE user_id = $1 AND actions_remaining > 0
RETURNING actions_remaining`,
[userId]
);
return rows[0] ?? null;
}
/**
* Resets ALL users' quota to a given value (used on world-seed wipeout).
* Users who have no row yet get one inserted.
*/
export async function resetAllUserActions(actionsRemaining, quotaResetAt) {
await usersPool.query(
`INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at)
SELECT id, $1, $2 FROM users
ON CONFLICT (user_id) DO UPDATE
SET actions_remaining = $1,
quota_reset_at = $2`,
[actionsRemaining, quotaResetAt]
);
}

8
server/healthcheck.js Normal file
View File

@@ -0,0 +1,8 @@
import http from "http";
const port = Number(process.env.PORT ?? 8080);
http
.get(`http://localhost:${port}/api/config`, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
})
.on("error", () => process.exit(1));

View File

@@ -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,
};
}

View File

@@ -122,3 +122,35 @@ export function computeTeamElementBonus(team, rows, elementWorth) {
}
return bonus;
}
// ── Military power computation ────────────────────────────────────────────────
const POP_LABEL_TO_KEY = new Map([
["Humains", "humans"],
["Presque'humains", "near"],
["Aliens", "aliens"],
]);
/**
* Compute total military power (in billions) for a team from DB grid rows.
*
* @param {string} team - "blue" or "red"
* @param {Array<{ has_planet: boolean, planet_json: object|null, discovered_by: string }>} rows
* @param {object} militaryPower - { humans: 10, near: 5, aliens: 1 }
* @returns {number} military power in billions
*/
export function computeTeamMilitaryPower(team, rows, militaryPower) {
let total = 0;
for (const row of rows) {
if (row.discovered_by !== team) continue;
if (!row.has_planet || !row.planet_json) continue;
const pop = row.planet_json.population;
if (!pop) continue;
const key = POP_LABEL_TO_KEY.get(pop.majority);
if (!key) continue;
const pct = militaryPower?.[key] ?? 0;
if (pct === 0) continue;
total += pop.billions * pct / 100;
}
return total;
}

View File

@@ -35,7 +35,7 @@ async function main() {
app.listen(PORT, () => {
const cfg = getConfig();
console.log(
`[server] Listening on :${PORT} cooldown=${cfg.clickCooldownSeconds}s wipe=${cfg.databaseWipeoutIntervalSeconds}s`
`[server] Listening on :${PORT} dailyQuota=${cfg.dailyActionQuota} wipe=${cfg.databaseWipeoutIntervalSeconds}s`
);
});

View File

@@ -1,6 +1,10 @@
import jwt from "jsonwebtoken";
export const JWT_SECRET = process.env.JWT_SECRET ?? "dev_secret_change_me";
if (!process.env.JWT_SECRET) {
throw new Error("[startup] JWT_SECRET environment variable is required but not set.");
}
export const JWT_SECRET = process.env.JWT_SECRET;
export function authMiddleware(req, res, next) {
const authHeader = req.headers["authorization"];

View File

@@ -2,7 +2,7 @@ import express from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { JWT_SECRET, authMiddleware } from "../middleware/auth.js";
import { createUser, getUserByUsername, getUserById } from "../db/usersDb.js";
import { createUser, getUserByUsername, getUserById, getTeamPlayerCounts } from "../db/usersDb.js";
const router = express.Router();
@@ -16,8 +16,8 @@ function issueToken(user) {
// POST /api/auth/register
router.post("/register", async (req, res) => {
const { username, email, password, team } = req.body ?? {};
if (!username || !email || !password || !team) {
const { username, password, team } = req.body ?? {};
if (!username || !password || !team) {
return res.status(400).json({ error: "missing_fields" });
}
if (team !== "blue" && team !== "red") {
@@ -31,7 +31,7 @@ router.post("/register", async (req, res) => {
}
try {
const passwordHash = await bcrypt.hash(password, 12);
const user = await createUser(username.trim(), email.trim().toLowerCase(), passwordHash, team);
const user = await createUser(username.trim(), passwordHash, team);
const token = issueToken(user);
return res.status(201).json({
token,
@@ -39,7 +39,6 @@ router.post("/register", async (req, res) => {
});
} catch (e) {
if (e.code === "23505") {
if (e.constraint?.includes("email")) return res.status(409).json({ error: "email_taken" });
return res.status(409).json({ error: "username_taken" });
}
console.error(e);
@@ -67,6 +66,17 @@ router.post("/login", async (req, res) => {
}
});
// GET /api/auth/player-counts
router.get("/player-counts", async (_req, res) => {
try {
const counts = await getTeamPlayerCounts();
return res.json(counts);
} catch (e) {
console.error(e);
return res.status(500).json({ error: "database_error" });
}
});
// GET /api/auth/me
router.get("/me", authMiddleware, async (req, res) => {
try {

View File

@@ -18,8 +18,30 @@ import {
setElementBonus,
getDbCreatedAt,
getVictoryPoints,
getActivePlayerCounts,
getActivePlayerIds,
getMilitaryDeductions,
addMilitaryDeduction,
recordCellAttack,
getCellAttackCount,
setTileOwner,
getTeamActionsRow,
resetTeamActions,
decrementTeamActions,
decrementTeamActionsBy,
checkTeamVisibility,
insertTeamVisibility,
getTeamVisibleCells,
} from "../db/gameDb.js";
import {
nextNoonUtc,
getUserActionsRow,
resetUserActions,
decrementUserActions,
getUsersByIds,
} from "../db/usersDb.js";
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
import { computeTeamMilitaryPower } from "../helpers/economy.js";
const router = express.Router();
@@ -31,47 +53,56 @@ router.get("/config", async (req, res) => {
const rot = cfg.databaseWipeoutIntervalSeconds;
const ws = computeWorldSeedState(rot);
let teamCooldownRemaining = 0;
let actionsRemaining = null;
let teamActionsRemaining = null;
const team = typeof req.query.team === "string" ? req.query.team : undefined;
if (team === "blue" || team === "red") {
const bonus = await getElementBonus(worldSeed);
const teamBonus = bonus[team] ?? 0;
const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100);
// Use per-user cooldown if auth token provided, else fall back to team-wide
const authHeader = req.headers["authorization"];
if (authHeader && authHeader.startsWith("Bearer ")) {
try {
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
const row = await getUserCooldown(worldSeed, payload.userId);
if (row) {
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
if (secondsSince < effectiveCooldown) {
teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince);
}
const bonus = await getElementBonus(worldSeed);
const teamBonus = bonus[team] ?? 0;
const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100));
const now = new Date();
const quotaRow = await getUserActionsRow(payload.userId);
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) {
actionsRemaining = effectiveQuota;
} else {
actionsRemaining = quotaRow.actions_remaining;
}
} catch { /* invalid token — return 0 */ }
} catch { /* invalid token — return null */ }
}
// Team-wide quota: compute current remaining without consuming
const now = new Date();
const teamRow = await getTeamActionsRow(team);
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
// Expired or unset: compute what it would be when refreshed
const rows = await getGridCells(worldSeed);
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
const milDeductions = await getMilitaryDeductions(worldSeed);
const milNet = milPower - (milDeductions[team] ?? 0);
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
teamActionsRemaining = cfg.teamActionQuota + milBonus;
} else {
const row = await getTeamCooldown(worldSeed, team);
if (row) {
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
if (secondsSince < effectiveCooldown) {
teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince);
}
}
teamActionsRemaining = teamRow.actions_remaining;
}
}
res.json({
clickCooldownSeconds: cfg.clickCooldownSeconds,
dailyActionQuota: cfg.dailyActionQuota,
databaseWipeoutIntervalSeconds: rot,
debugModeForTeams: cfg.debugModeForTeams,
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
worldSeed: ws.worldSeed,
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
teamCooldownRemaining,
actionsRemaining,
teamActionsRemaining,
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
elementWorth: cfg.elementWorth ?? {},
militaryPower: cfg.militaryPower ?? {},
});
} catch (e) {
console.error(e);
@@ -79,7 +110,7 @@ router.get("/config", async (req, res) => {
}
});
// GET /api/grid/:seed
// GET /api/grid/:seed (auth-aware: returns only team-visible cells)
router.get("/grid/:seed", async (req, res) => {
const seed = decodeURIComponent(req.params.seed || "");
try {
@@ -93,7 +124,14 @@ router.get("/grid/:seed", async (req, res) => {
).seedPeriodEndsAtUtc,
});
}
const rows = await getGridCells(seed);
let rows = [];
const authHeader = req.headers["authorization"];
if (authHeader && authHeader.startsWith("Bearer ")) {
try {
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
rows = await getTeamVisibleCells(worldSeed, payload.team);
} catch { /* invalid token — return empty grid */ }
}
res.json({ seed, cells: rows });
} catch (e) {
console.error(e);
@@ -101,10 +139,10 @@ router.get("/grid/:seed", async (req, res) => {
}
});
// POST /api/cell/reveal
// POST /api/cell/reveal — reveals a cell for THIS team only (private visibility)
router.post("/cell/reveal", authMiddleware, async (req, res) => {
const seed = String(req.body?.seed ?? "");
const team = req.user.team; // taken from verified JWT, not request body
const team = req.user.team;
const userId = req.user.userId;
const x = Number(req.body?.x);
const y = Number(req.body?.y);
@@ -119,44 +157,42 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
return res.status(410).json({ error: "seed_expired", worldSeed });
}
const cfg = getConfig();
if (cfg.clickCooldownSeconds > 0) {
const bonus = await getElementBonus(worldSeed);
const teamBonus = bonus[team] ?? 0;
const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100);
const cooldownRow = await getUserCooldown(worldSeed, userId);
if (cooldownRow) {
const secondsSince = (Date.now() - new Date(cooldownRow.last_reveal).getTime()) / 1000;
if (secondsSince < effectiveCooldown) {
return res.status(429).json({
error: "cooldown_active",
team,
remainingSeconds: Math.ceil(effectiveCooldown - secondsSince),
cooldownSeconds: effectiveCooldown,
});
// Check if already revealed by this team (free re-view, no action cost)
const alreadyVisible = await checkTeamVisibility(worldSeed, team, x, y);
if (!alreadyVisible) {
// First reveal: deduct one user action
const cfg = getConfig();
if (cfg.dailyActionQuota > 0) {
const bonus = await getElementBonus(worldSeed);
const teamBonus = bonus[team] ?? 0;
const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100));
const now = new Date();
const quotaRow = await getUserActionsRow(userId);
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) {
await resetUserActions(userId, effectiveQuota - 1, nextNoonUtc().toISOString());
} else {
const updated = await decrementUserActions(userId);
if (!updated) {
return res.status(429).json({ error: "quota_exhausted", actionsRemaining: 0 });
}
}
}
// Mark as visible for this team
await insertTeamVisibility(worldSeed, team, x, y);
}
// Ensure cell data is in grid_cells (discovered_by stays NULL = neutral)
const cell = computeCell(seed, x, y);
const planetJson = cell.planet ? JSON.stringify(cell.planet) : null;
const inserted = await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team);
await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson);
if (inserted) {
await upsertUserCooldown(worldSeed, userId, team);
return res.json(rowToCellPayload(inserted));
}
// Track activity for active-player count
await upsertUserCooldown(worldSeed, userId, team);
const existing = await getExistingCell(seed, x, y);
if (!existing) return res.status(500).json({ error: "insert_race" });
if (existing.discovered_by !== team) {
return res.status(409).json({
error: "taken_by_other_team",
discoveredBy: existing.discovered_by,
cell: rowToCellPayload(existing),
});
}
return res.json(rowToCellPayload(existing));
} catch (e) {
console.error(e);
@@ -164,6 +200,105 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
}
});
// POST /api/cell/capture — spend team actions to capture a planet
router.post("/cell/capture", authMiddleware, async (req, res) => {
const seed = String(req.body?.seed ?? "");
const team = req.user.team;
const x = Number(req.body?.x);
const y = Number(req.body?.y);
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
return res.status(400).json({ error: "invalid_body" });
}
try {
const worldSeed = await ensureSeedEpoch();
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
const existing = await getExistingCell(worldSeed, x, y);
if (!existing) return res.status(404).json({ error: "cell_not_found" });
if (!existing.has_planet) return res.status(409).json({ error: "no_planet" });
if (existing.discovered_by === team) return res.status(409).json({ error: "already_owned" });
// Compute capture cost (1 action per 10 billion population, doubled if opponent-controlled)
const planet = existing.planet_json;
const billions = planet?.population?.billions ?? 0;
const baseCost = Math.max(1, Math.ceil(billions / 10));
const isOpponentControlled = existing.discovered_by !== null && existing.discovered_by !== team;
const cost = isOpponentControlled ? baseCost * 2 : baseCost;
// Consume team actions
const cfg = getConfig();
const now = new Date();
const teamRow = await getTeamActionsRow(team);
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
// Compute fresh quota
const rows = await getGridCells(worldSeed);
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
const milDeductions = await getMilitaryDeductions(worldSeed);
const milNet = milPower - (milDeductions[team] ?? 0);
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
const totalActions = cfg.teamActionQuota + milBonus;
if (totalActions < cost) {
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: totalActions });
}
await resetTeamActions(team, totalActions - cost, nextNoonUtc().toISOString());
} else {
if (teamRow.actions_remaining < cost) {
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: teamRow.actions_remaining });
}
const updated = await decrementTeamActionsBy(team, cost);
if (!updated) {
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: 0 });
}
}
// Transfer ownership to capturing team
await setTileOwner(worldSeed, x, y, team, req.user.username);
const updatedCell = await getExistingCell(worldSeed, x, y);
const updatedTeamRow = await getTeamActionsRow(team);
res.json({
success: true,
cell: rowToCellPayload(updatedCell),
teamActionsRemaining: updatedTeamRow?.actions_remaining ?? null,
});
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// GET /api/team-quota?team=blue|red
router.get("/team-quota", async (req, res) => {
const team = typeof req.query.team === "string" ? req.query.team : "";
if (team !== "blue" && team !== "red") {
return res.status(400).json({ error: "invalid_team" });
}
try {
const worldSeed = await ensureSeedEpoch();
const cfg = getConfig();
const now = new Date();
const teamRow = await getTeamActionsRow(team);
let actionsRemaining;
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
const rows = await getGridCells(worldSeed);
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
const milDeductions = await getMilitaryDeductions(worldSeed);
const milNet = milPower - (milDeductions[team] ?? 0);
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
actionsRemaining = cfg.teamActionQuota + milBonus;
} else {
actionsRemaining = teamRow.actions_remaining;
}
res.json({ team, actionsRemaining });
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// GET /api/econ-scores
router.get("/econ-scores", async (_req, res) => {
try {
@@ -246,6 +381,36 @@ router.get("/victory-points", async (_req, res) => {
}
});
// GET /api/active-players
router.get("/active-players", async (_req, res) => {
try {
const worldSeed = await ensureSeedEpoch();
const counts = await getActivePlayerCounts(worldSeed);
res.json(counts);
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// 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 {
@@ -263,4 +428,81 @@ router.get("/scores", async (_req, res) => {
}
});
// GET /api/military-deductions
router.get("/military-deductions", async (_req, res) => {
try {
const worldSeed = await ensureSeedEpoch();
const deductions = await getMilitaryDeductions(worldSeed);
res.json(deductions);
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// POST /api/military/attack body: { seed, x, y }
// Requires auth. The attacker's team must have enough computed military power.
// Deducts exactly 1.0 billion (= 1000 M) and logs the attack on the cell.
router.post("/military/attack", authMiddleware, async (req, res) => {
const seed = String(req.body?.seed ?? "");
const x = Number(req.body?.x);
const y = Number(req.body?.y);
const attackingTeam = req.user.team;
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
return res.status(400).json({ error: "invalid_body" });
}
try {
const worldSeed = await ensureSeedEpoch();
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
// Target cell must exist, have a planet, and belong to the opposing team
const existing = await getExistingCell(worldSeed, x, y);
if (!existing) return res.status(404).json({ error: "cell_not_found" });
if (!existing.discovered_by || existing.discovered_by === attackingTeam) {
return res.status(409).json({ error: "cannot_attack_own_or_neutral_tile" });
}
// Deduct 1 billion (1.0 in "billions" unit) from the attacking team
const COST_BILLIONS = 1.0;
await addMilitaryDeduction(worldSeed, attackingTeam, COST_BILLIONS);
// Transfer tile ownership to the attacking team
await setTileOwner(worldSeed, x, y, attackingTeam, req.user.username);
// Record the attack event
await recordCellAttack(worldSeed, x, y, attackingTeam);
const deductions = await getMilitaryDeductions(worldSeed);
const updatedCell = await getExistingCell(worldSeed, x, y);
res.json({
success: true,
cell: rowToCellPayload(updatedCell),
deductions,
});
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
// GET /api/cell/attacks?x=&y=
router.get("/cell/attacks", async (req, res) => {
const x = Number(req.query.x);
const y = Number(req.query.y);
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
return res.status(400).json({ error: "invalid_params" });
}
try {
const worldSeed = await ensureSeedEpoch();
const count = await getCellAttackCount(worldSeed, x, y);
res.json({ x, y, attackCount: count });
} catch (e) {
console.error(e);
res.status(500).json({ error: "database_error" });
}
});
export default router;