Initial commit
**Motivations:** - Initialisation du versionning git pour le projet **Root causes:** - N/A (Nouveau projet) **Correctifs:** - N/A **Evolutions:** - Structure initiale du projet - Ajout du .gitignore **Pages affectées:** - Tous les fichiers
This commit is contained in:
40
server/README.md
Normal file
40
server/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Build a Zoo — API server
|
||||
|
||||
Node + Express + PostgreSQL. Auth by Ed25519 keypair (pseudo, no password). Zoos and game state persisted in DB; bots ensure minimum zoo density on the map.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node 18+
|
||||
- PostgreSQL (create a database, e.g. `builazoo`)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
createdb builazoo # or your DB name
|
||||
psql -d builazoo -f schema.sql
|
||||
```
|
||||
|
||||
Set environment:
|
||||
|
||||
- `DATABASE_URL`: connection string (default `postgres://localhost/builazoo`)
|
||||
- `PORT`: HTTP port (default `3000`)
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
API base URL is `http://localhost:3000` (or your host/port). From the web client, set `window.BUILAZOO_API_URL` to this URL before loading the app (e.g. in HTML or via build env) to enable sign-up and sync. For local testing you can also open the web app with `?api=http://localhost:3000` (or your API origin).
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/health` — health check
|
||||
- `POST /api/auth/register` — create account (body: `{ pseudo }`, header: `X-Public-Key` base64url)
|
||||
- `GET /api/zoos` — list zoos for map (creates bots if below `minZoos`)
|
||||
- `GET /api/zoos/me` — get my zoo + game_state (signed)
|
||||
- `POST /api/zoos/me` — create my zoo (signed, body: `{ name?, game_state }`)
|
||||
- `PATCH /api/zoos/me` — update game_state (signed, body: `{ game_state }`)
|
||||
|
||||
Signed requests use headers: `X-Public-Key`, `X-Signature`, `X-Timestamp`. Message signed = `method + path + timestamp + SHA256(body)`.
|
||||
36
server/auth.js
Normal file
36
server/auth.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export function verifySignature(publicKeyBase64, signatureBase64, message) {
|
||||
try {
|
||||
const publicKeyBuf = Buffer.from(publicKeyBase64, "base64url");
|
||||
const signatureBuf = Buffer.from(signatureBase64, "base64url");
|
||||
const keyObject = crypto.createPublicKey({
|
||||
key: publicKeyBuf,
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
return crypto.verify(null, Buffer.from(message, "utf8"), keyObject, signatureBuf);
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build message that client must sign: method + path + timestamp + bodyHash */
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {string} path
|
||||
* @param {string} timestamp
|
||||
* @param {string} bodyHash
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSignMessage(method, path, timestamp, bodyHash) {
|
||||
return `${method}\n${path}\n${timestamp}\n${bodyHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} body
|
||||
* @returns {string} hex hash of body
|
||||
*/
|
||||
export function hashBody(body) {
|
||||
return crypto.createHash("sha256").update(body || "").digest("hex");
|
||||
}
|
||||
24
server/bot-state.js
Normal file
24
server/bot-state.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Server-side initial bot state. Mirrors web/js/bot-zoo.js createInitialBotState
|
||||
* so the server does not depend on browser modules.
|
||||
*/
|
||||
|
||||
const PROFILE_OPTIONS = ["fast", "slow", "balanced"];
|
||||
const INITIAL_COINS_MIN = 150;
|
||||
const INITIAL_COINS_MAX = 450;
|
||||
|
||||
/**
|
||||
* @returns {{ coins: number, plotLevel: number, conveyorLevel: number, truckLevel: number, profile: string, lastTickAt: number }}
|
||||
*/
|
||||
export function createInitialBotState() {
|
||||
const coins = INITIAL_COINS_MIN + Math.floor(Math.random() * (INITIAL_COINS_MAX - INITIAL_COINS_MIN + 1));
|
||||
const profile = PROFILE_OPTIONS[Math.floor(Math.random() * PROFILE_OPTIONS.length)];
|
||||
return {
|
||||
coins,
|
||||
plotLevel: 1,
|
||||
conveyorLevel: 1,
|
||||
truckLevel: 1,
|
||||
profile,
|
||||
lastTickAt: 0,
|
||||
};
|
||||
}
|
||||
58
server/bot-tick.js
Normal file
58
server/bot-tick.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Server-side bot tick: load bot zoos, run same logic as client tickBotZoos, persist.
|
||||
* Uses web/js/bot-zoo.js so bot rules stay in one place.
|
||||
*/
|
||||
|
||||
import { getBotZoosForTick, updateBotZooState, expireSaleListings, getActiveSaleListings, placeBid } from "./db.js";
|
||||
import { tickBotZoos } from "../web/js/bot-zoo.js";
|
||||
|
||||
const BOT_TICK_INTERVAL_MS = 15 * 1000;
|
||||
let lastTickAt = 0;
|
||||
|
||||
/**
|
||||
* Run one bot tick and persist all bot zoos.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function runBotTick() {
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
const nowMs = Date.now();
|
||||
const dt = lastTickAt > 0 ? Math.min((nowMs - lastTickAt) / 1000, 60) : 10;
|
||||
lastTickAt = nowMs;
|
||||
|
||||
await expireSaleListings();
|
||||
|
||||
const botZoos = await getBotZoosForTick();
|
||||
if (botZoos.length === 0) return;
|
||||
|
||||
const activeListings = await getActiveSaleListings();
|
||||
for (const listing of activeListings) {
|
||||
const minBid = (listing.best_bid_amount ?? listing.initial_price) + 1;
|
||||
const bidders = botZoos.filter(
|
||||
(z) => z.id !== listing.seller_zoo_id && (z.botState?.coins ?? 0) >= minBid
|
||||
);
|
||||
if (bidders.length > 0) {
|
||||
const bidder = bidders[Math.floor(Math.random() * bidders.length)];
|
||||
const result = await placeBid(listing.id, bidder.id, minBid);
|
||||
if (result.ok) break;
|
||||
}
|
||||
}
|
||||
|
||||
const state = { worldZoos: botZoos };
|
||||
tickBotZoos(state, nowUnix, dt);
|
||||
|
||||
for (const zoo of botZoos) {
|
||||
await updateBotZooState(zoo.id, zoo.animalWeights, zoo.botState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic bot tick (call after server listen).
|
||||
* @returns {void}
|
||||
*/
|
||||
export function startBotTickInterval() {
|
||||
setInterval(() => {
|
||||
runBotTick().catch((err) => {
|
||||
console.error("bot tick failed", err);
|
||||
});
|
||||
}, BOT_TICK_INTERVAL_MS);
|
||||
}
|
||||
489
server/db.js
Normal file
489
server/db.js
Normal file
@@ -0,0 +1,489 @@
|
||||
import pg from "pg";
|
||||
import parse from "pg-connection-string";
|
||||
import { createInitialBotState } from "./bot-state.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || "postgres://localhost/builazoo";
|
||||
const parsed = parse(connectionString);
|
||||
const poolConfig = {
|
||||
host: parsed.host || "localhost",
|
||||
port: Number(parsed.port) || 5432,
|
||||
database: parsed.database || "builazoo",
|
||||
user: parsed.user,
|
||||
password: typeof parsed.password === "string" ? parsed.password : "",
|
||||
};
|
||||
if (process.env.PGPASSWORD !== undefined && process.env.PGPASSWORD !== null) poolConfig.password = String(process.env.PGPASSWORD);
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ mapWidth: number, mapHeight: number, minZoos: number }>}
|
||||
*/
|
||||
export async function getMapParams() {
|
||||
const res = await pool.query(
|
||||
"SELECT value FROM map_config WHERE key = 'params'"
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) {
|
||||
return { mapWidth: 100, mapHeight: 100, minZoos: 5 };
|
||||
}
|
||||
const v = row.value;
|
||||
return {
|
||||
mapWidth: Number(v?.mapWidth) || 100,
|
||||
mapHeight: Number(v?.mapHeight) || 100,
|
||||
minZoos: Number(v?.minZoos) || 5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} publicKey
|
||||
* @returns {Promise<{ id: string, pseudo: string } | null>}
|
||||
*/
|
||||
export async function getAccountByPublicKey(publicKey) {
|
||||
const res = await pool.query(
|
||||
"SELECT id, pseudo FROM accounts WHERE public_key = $1",
|
||||
[publicKey]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) return null;
|
||||
return { id: row.id, pseudo: row.pseudo };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} publicKey
|
||||
* @param {string} pseudo
|
||||
* @returns {Promise<{ id: string, pseudo: string }>}
|
||||
*/
|
||||
export async function createAccount(publicKey, pseudo) {
|
||||
const res = await pool.query(
|
||||
"INSERT INTO accounts (public_key, pseudo) VALUES ($1, $2) RETURNING id, pseudo",
|
||||
[publicKey, pseudo]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
return { id: row.id, pseudo: row.pseudo };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accountId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateLastSeen(accountId) {
|
||||
await pool.query(
|
||||
"UPDATE accounts SET last_seen_at = now() WHERE id = $1",
|
||||
[accountId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accountId
|
||||
* @returns {Promise<{ id: string, name: string, x: number, y: number, is_bot: boolean, animal_weights: object, game_state: object | null } | null>}
|
||||
*/
|
||||
export async function getZooByAccountId(accountId) {
|
||||
const res = await pool.query(
|
||||
"SELECT id, name, x, y, is_bot, animal_weights, game_state FROM zoos WHERE account_id = $1",
|
||||
[accountId]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
x: Number(row.x),
|
||||
y: Number(row.y),
|
||||
is_bot: row.is_bot,
|
||||
animal_weights: row.animal_weights || {},
|
||||
game_state: row.game_state,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common zoo row fields: id, name, x, y with numeric coords.
|
||||
* @param {Record<string, unknown>} row
|
||||
* @returns {{ id: string, name: string, x: number, y: number }}
|
||||
*/
|
||||
function mapZooRowBase(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
x: Number(row.x),
|
||||
y: Number(row.y),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Array<{ id: string, name: string, x: number, y: number, animal_weights: object, game_state: object | null }>>}
|
||||
*/
|
||||
export async function getAllZoos() {
|
||||
const res = await pool.query(
|
||||
"SELECT id, name, x, y, animal_weights, game_state FROM zoos ORDER BY is_bot, name"
|
||||
);
|
||||
return res.rows.map((row) => ({
|
||||
...mapZooRowBase(row),
|
||||
animal_weights: row.animal_weights || {},
|
||||
game_state: row.game_state ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ accountId: string, name: string, x: number, y: number, gameState: object }} opts
|
||||
* @returns {Promise<{ id: string }>}
|
||||
*/
|
||||
export async function createZoo(opts) {
|
||||
const { accountId, name, x, y, gameState } = opts;
|
||||
const res = await pool.query(
|
||||
"INSERT INTO zoos (account_id, name, x, y, is_bot, animal_weights, game_state) VALUES ($1, $2, $3, $4, false, $5, $6) RETURNING id",
|
||||
[accountId, name, x, y, "{}", gameState]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} zooId
|
||||
* @returns {Promise<{ id: string, name: string, x: number, y: number, is_bot: boolean, account_id: string | null, animal_weights: object, game_state: object | null } | null>}
|
||||
*/
|
||||
export async function getZooById(zooId) {
|
||||
const res = await pool.query(
|
||||
"SELECT id, name, x, y, is_bot, account_id, animal_weights, game_state FROM zoos WHERE id = $1",
|
||||
[zooId]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
...mapZooRowBase(row),
|
||||
is_bot: row.is_bot,
|
||||
account_id: row.account_id,
|
||||
animal_weights: row.animal_weights || {},
|
||||
game_state: row.game_state,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} zooId
|
||||
* @param {object} gameState
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateZooGameState(zooId, gameState) {
|
||||
await pool.query(
|
||||
"UPDATE zoos SET game_state = $1, updated_at = now() WHERE id = $2",
|
||||
[JSON.stringify(gameState), zooId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function countPlayerZoos() {
|
||||
const res = await pool.query(
|
||||
"SELECT COUNT(*) AS n FROM zoos WHERE is_bot = false"
|
||||
);
|
||||
return Number(res.rows[0]?.n) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {object} animalWeights
|
||||
* @returns {Promise<string>} zoo id
|
||||
*/
|
||||
export async function createBotZoo(x, y, animalWeights) {
|
||||
const gameState = createInitialBotState();
|
||||
const res = await pool.query(
|
||||
"INSERT INTO zoos (account_id, name, x, y, is_bot, animal_weights, game_state) VALUES (NULL, $1, $2, $3, true, $4, $5) RETURNING id",
|
||||
[`Zoo bot ${x.toFixed(0)}-${y.toFixed(0)}`, x, y, JSON.stringify(animalWeights), JSON.stringify(gameState)]
|
||||
);
|
||||
return res.rows[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bot zoos for server-side tick (id, name, x, y, animal_weights, game_state).
|
||||
* @returns {Promise<Array<{ id: string, name: string, x: number, y: number, animalWeights: object, botState: object }>>}
|
||||
*/
|
||||
export async function getBotZoosForTick() {
|
||||
const res = await pool.query(
|
||||
"SELECT id, name, x, y, animal_weights, game_state FROM zoos WHERE is_bot = true"
|
||||
);
|
||||
return res.rows.map((row) => ({
|
||||
...mapZooRowBase(row),
|
||||
animalWeights: row.animal_weights || {},
|
||||
botState: row.game_state || createInitialBotState(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist bot zoo state after tick.
|
||||
* @param {string} zooId
|
||||
* @param {object} animalWeights
|
||||
* @param {object} gameState
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateBotZooState(zooId, animalWeights, gameState) {
|
||||
await pool.query(
|
||||
"UPDATE zoos SET animal_weights = $1, game_state = $2, updated_at = now() WHERE id = $3 AND is_bot = true",
|
||||
[JSON.stringify(animalWeights), JSON.stringify(gameState), zooId]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sale listings (phase 10) ---
|
||||
|
||||
const SALE_STATUS = { ACTIVE: "active", SOLD: "sold", EXPIRED: "expired", REJECTED: "rejected", VALIDATED: "validated" };
|
||||
|
||||
/** Deferred validation delay in seconds (10 minutes). */
|
||||
const SALE_VALIDATION_DELAY_SECONDS = 10 * 60;
|
||||
|
||||
/**
|
||||
* Map a sale_listings row to a listing object. Missing columns (e.g. sold_at on active-only SELECT) become undefined.
|
||||
* @param {Record<string, unknown>} row
|
||||
* @returns {{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: Date, status: string, best_bid_amount: number | null, best_bidder_zoo_id: string | null, sold_at?: Date | null, validated_at?: Date | null, reproduction_score_at_sale: number | null, delivered_at?: Date | null, created_at?: Date }}
|
||||
*/
|
||||
function mapSaleListingRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
seller_zoo_id: row.seller_zoo_id,
|
||||
animal_id: row.animal_id,
|
||||
is_baby: Boolean(row.is_baby),
|
||||
initial_price: Number(row.initial_price),
|
||||
end_at: row.end_at,
|
||||
status: String(row.status),
|
||||
best_bid_amount: (row.best_bid_amount !== null && row.best_bid_amount !== undefined) ? Number(row.best_bid_amount) : null,
|
||||
best_bidder_zoo_id: row.best_bidder_zoo_id ?? null,
|
||||
sold_at: row.sold_at ?? undefined,
|
||||
validated_at: row.validated_at ?? undefined,
|
||||
reproduction_score_at_sale: (row.reproduction_score_at_sale !== null && row.reproduction_score_at_sale !== undefined) ? Number(row.reproduction_score_at_sale) : null,
|
||||
delivered_at: row.delivered_at ?? undefined,
|
||||
created_at: row.created_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ sellerZooId: string, animalId: string, isBaby: boolean, initialPrice: number, endAt: string, reproductionScoreAtSale?: number }} opts
|
||||
* @returns {Promise<{ id: string }>}
|
||||
*/
|
||||
export async function createSaleListing(opts) {
|
||||
const { sellerZooId, animalId, isBaby, initialPrice, endAt, reproductionScoreAtSale } = opts;
|
||||
const res = await pool.query(
|
||||
`INSERT INTO sale_listings (seller_zoo_id, animal_id, is_baby, initial_price, end_at, reproduction_score_at_sale)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[sellerZooId, animalId, isBaby, initialPrice, endAt, reproductionScoreAtSale ?? null]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} listingId
|
||||
* @returns {Promise<{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: Date, status: string, best_bid_amount: number | null, best_bidder_zoo_id: string | null, sold_at: Date | null, validated_at: Date | null, reproduction_score_at_sale: number | null, delivered_at: Date | null, created_at: Date } | null>}
|
||||
*/
|
||||
export async function getSaleListingById(listingId) {
|
||||
const res = await pool.query(
|
||||
"SELECT id, seller_zoo_id, animal_id, is_baby, initial_price, end_at, status, best_bid_amount, best_bidder_zoo_id, sold_at, validated_at, reproduction_score_at_sale, delivered_at, created_at FROM sale_listings WHERE id = $1",
|
||||
[listingId]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
return row ? mapSaleListingRow(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active listings (for marketplace).
|
||||
* @returns {Promise<Array<{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: Date, status: string, best_bid_amount: number | null, best_bidder_zoo_id: string | null, reproduction_score_at_sale: number | null }>>}
|
||||
*/
|
||||
export async function getActiveSaleListings() {
|
||||
const res = await pool.query(
|
||||
`SELECT id, seller_zoo_id, animal_id, is_baby, initial_price, end_at, status, best_bid_amount, best_bidder_zoo_id, reproduction_score_at_sale
|
||||
FROM sale_listings WHERE status = $1 ORDER BY end_at ASC`,
|
||||
[SALE_STATUS.ACTIVE]
|
||||
);
|
||||
return res.rows.map(mapSaleListingRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listings relevant to a zoo: as seller (any status), as buyer (sold to me, not yet delivered), plus active for browsing.
|
||||
* @param {string} zooId
|
||||
* @returns {Promise<{ asSeller: Array<object>, asBuyerUndelivered: Array<object>, active: Array<object> }>}
|
||||
*/
|
||||
export async function getSalesForZoo(zooId) {
|
||||
const [sellerRes, buyerRes, activeRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT id, seller_zoo_id, animal_id, is_baby, initial_price, end_at, status, best_bid_amount, best_bidder_zoo_id, sold_at, validated_at, reproduction_score_at_sale, delivered_at, created_at
|
||||
FROM sale_listings WHERE seller_zoo_id = $1 ORDER BY created_at DESC`,
|
||||
[zooId]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT id, seller_zoo_id, animal_id, is_baby, initial_price, end_at, status, best_bid_amount, best_bidder_zoo_id, sold_at, validated_at, reproduction_score_at_sale, delivered_at, created_at
|
||||
FROM sale_listings WHERE best_bidder_zoo_id = $1 AND status = ANY($2::text[]) AND delivered_at IS NULL ORDER BY sold_at DESC`,
|
||||
[zooId, [SALE_STATUS.SOLD, SALE_STATUS.VALIDATED]]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT id, seller_zoo_id, animal_id, is_baby, initial_price, end_at, status, best_bid_amount, best_bidder_zoo_id, reproduction_score_at_sale
|
||||
FROM sale_listings WHERE status = $1 ORDER BY end_at ASC`,
|
||||
[SALE_STATUS.ACTIVE]
|
||||
),
|
||||
]);
|
||||
return {
|
||||
asSeller: sellerRes.rows.map(mapSaleListingRow),
|
||||
asBuyerUndelivered: buyerRes.rows.map(mapSaleListingRow),
|
||||
active: activeRes.rows.map(mapSaleListingRow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load listing and validate it is active and seller is the given zoo. Used by acceptSale and rejectSale.
|
||||
* @param {string} listingId
|
||||
* @param {string} sellerZooId
|
||||
* @returns {Promise<{ ok: true, listing: object } | { ok: false, reason: string }>}
|
||||
*/
|
||||
async function validateListingForSeller(listingId, sellerZooId) {
|
||||
const listing = await getSaleListingById(listingId);
|
||||
if (!listing) return { ok: false, reason: "ListingNotFound" };
|
||||
if (listing.status !== SALE_STATUS.ACTIVE) return { ok: false, reason: "ListingNotActive" };
|
||||
if (listing.seller_zoo_id !== sellerZooId) return { ok: false, reason: "NotSeller" };
|
||||
return { ok: true, listing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Place or update bid for a listing. Only if listing is active and amount > current best_bid_amount (or initial_price).
|
||||
* @param {string} listingId
|
||||
* @param {string} bidderZooId
|
||||
* @param {number} amount
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
export async function placeBid(listingId, bidderZooId, amount) {
|
||||
const listing = await getSaleListingById(listingId);
|
||||
if (!listing) return { ok: false, reason: "ListingNotFound" };
|
||||
if (listing.status !== SALE_STATUS.ACTIVE) return { ok: false, reason: "ListingNotActive" };
|
||||
const minAmount = listing.best_bid_amount ?? listing.initial_price;
|
||||
if (amount <= minAmount) return { ok: false, reason: "BidTooLow" };
|
||||
await pool.query(
|
||||
"INSERT INTO sale_bids (listing_id, bidder_zoo_id, amount) VALUES ($1, $2, $3) ON CONFLICT (listing_id, bidder_zoo_id) DO UPDATE SET amount = $3, created_at = now()",
|
||||
[listingId, bidderZooId, amount]
|
||||
);
|
||||
await pool.query(
|
||||
"UPDATE sale_listings SET best_bid_amount = $1, best_bidder_zoo_id = $2 WHERE id = $3",
|
||||
[amount, bidderZooId, listingId]
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seller accepts the current best bid: mark sold, set validated_at = now() + 10 minutes. Coins are transferred later by processValidatedSales().
|
||||
* @param {string} listingId
|
||||
* @param {string} sellerZooId
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
export async function acceptSale(listingId, sellerZooId) {
|
||||
const validated = await validateListingForSeller(listingId, sellerZooId);
|
||||
if (!validated.ok) return { ok: false, reason: validated.reason };
|
||||
const { listing } = validated;
|
||||
const buyerZooId = listing.best_bidder_zoo_id;
|
||||
const amount = listing.best_bid_amount;
|
||||
if (!buyerZooId || amount === null || amount === undefined) return { ok: false, reason: "NoBid" };
|
||||
const buyerZoo = await getZooById(buyerZooId);
|
||||
const sellerZoo = await getZooById(sellerZooId);
|
||||
if (!buyerZoo || !buyerZoo.game_state) return { ok: false, reason: "BuyerStateMissing" };
|
||||
if (!sellerZoo || !sellerZoo.game_state) return { ok: false, reason: "SellerStateMissing" };
|
||||
const buyerState = buyerZoo.game_state;
|
||||
const buyerCoins = Number(buyerState.coins ?? 0);
|
||||
if (buyerCoins < amount) return { ok: false, reason: "BuyerInsufficientCoins" };
|
||||
await pool.query(
|
||||
"UPDATE sale_listings SET status = $1, sold_at = now(), validated_at = now() + ($2::text || ' seconds')::interval WHERE id = $3",
|
||||
[SALE_STATUS.SOLD, String(SALE_VALIDATION_DELAY_SECONDS), listingId]
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process sold listings whose validated_at <= now(): transfer coins (buyer -= amount, seller += amount), set status = 'validated'.
|
||||
* @returns {Promise<number>} count of listings processed
|
||||
*/
|
||||
export async function processValidatedSales() {
|
||||
const res = await pool.query(
|
||||
`SELECT id, seller_zoo_id, best_bidder_zoo_id, best_bid_amount FROM sale_listings
|
||||
WHERE status = $1 AND validated_at IS NOT NULL AND validated_at <= now()`,
|
||||
[SALE_STATUS.SOLD]
|
||||
);
|
||||
let count = 0;
|
||||
for (const row of res.rows) {
|
||||
const buyerZooId = row.best_bidder_zoo_id;
|
||||
const sellerZooId = row.seller_zoo_id;
|
||||
const amount = Number(row.best_bid_amount);
|
||||
if (buyerZooId && Number.isFinite(amount)) {
|
||||
const buyerZoo = await getZooById(buyerZooId);
|
||||
const sellerZoo = await getZooById(sellerZooId);
|
||||
if (buyerZoo?.game_state && sellerZoo?.game_state) {
|
||||
const buyerState = buyerZoo.game_state;
|
||||
const sellerState = sellerZoo.game_state;
|
||||
const buyerCoins = Number(buyerState.coins ?? 0);
|
||||
const sellerCoins = Number(sellerState.coins ?? 0);
|
||||
buyerState.coins = buyerCoins - amount;
|
||||
sellerState.coins = sellerCoins + amount;
|
||||
await updateZooGameState(buyerZooId, buyerState);
|
||||
await updateZooGameState(sellerZooId, sellerState);
|
||||
await pool.query(
|
||||
"UPDATE sale_listings SET status = $1 WHERE id = $2",
|
||||
[SALE_STATUS.VALIDATED, row.id]
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seller rejects the sale (listing stays active; best bid is cleared so seller can accept a different bid later if any).
|
||||
* @param {string} listingId
|
||||
* @param {string} sellerZooId
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
export async function rejectSale(listingId, sellerZooId) {
|
||||
const validated = await validateListingForSeller(listingId, sellerZooId);
|
||||
if (!validated.ok) return { ok: false, reason: validated.reason };
|
||||
await pool.query(
|
||||
"UPDATE sale_listings SET best_bid_amount = NULL, best_bidder_zoo_id = NULL WHERE id = $1",
|
||||
[listingId]
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark listing as delivered (buyer has applied it to their zoo).
|
||||
* @param {string} listingId
|
||||
* @param {string} buyerZooId
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
export async function markSaleDelivered(listingId, buyerZooId) {
|
||||
const listing = await getSaleListingById(listingId);
|
||||
if (!listing) return { ok: false, reason: "ListingNotFound" };
|
||||
if (listing.status !== SALE_STATUS.VALIDATED) return { ok: false, reason: "NotValidated" };
|
||||
if (listing.best_bidder_zoo_id !== buyerZooId) return { ok: false, reason: "NotBuyer" };
|
||||
if (listing.delivered_at) return { ok: true };
|
||||
await pool.query("UPDATE sale_listings SET delivered_at = now() WHERE id = $1", [listingId]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire active listings past end_at: set status=expired; if is_baby, increment seller's game_state.deathCountRecent.
|
||||
* @returns {Promise<number>} count of expired listings
|
||||
*/
|
||||
export async function expireSaleListings() {
|
||||
const res = await pool.query(
|
||||
`SELECT id, seller_zoo_id, is_baby FROM sale_listings
|
||||
WHERE status = $1 AND end_at < now()`,
|
||||
[SALE_STATUS.ACTIVE]
|
||||
);
|
||||
let count = 0;
|
||||
for (const row of res.rows) {
|
||||
await pool.query("UPDATE sale_listings SET status = $1 WHERE id = $2", [SALE_STATUS.EXPIRED, row.id]);
|
||||
if (row.is_baby) {
|
||||
const zoo = await getZooById(row.seller_zoo_id);
|
||||
if (zoo && zoo.game_state) {
|
||||
const state = zoo.game_state;
|
||||
state.deathCountRecent = (Number(state.deathCountRecent) || 0) + 1;
|
||||
await updateZooGameState(row.seller_zoo_id, state);
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export { pool };
|
||||
10
server/env.js
Normal file
10
server/env.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Load .env from project root before any other module that uses process.env.
|
||||
* Must be imported first in index.js.
|
||||
*/
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.join(__dirname, "..", ".env"), override: true });
|
||||
104
server/index.js
Normal file
104
server/index.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import "./env.js";
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import cors from "cors";
|
||||
import zoosRouter from "./routes/zoos.js";
|
||||
import salesRouter from "./routes/sales.js";
|
||||
import { createAccount } from "./db.js";
|
||||
import { startBotTickInterval } from "./bot-tick.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webDir = path.join(__dirname, "..", "web");
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
|
||||
app.use(
|
||||
"/api/zoos",
|
||||
express.raw({ type: "*/*", limit: "2mb" }),
|
||||
(req, res, next) => {
|
||||
req.bodyRaw =
|
||||
req.body && Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
||||
try {
|
||||
req.body = req.bodyRaw ? JSON.parse(req.bodyRaw) : {};
|
||||
} catch {
|
||||
req.body = {};
|
||||
}
|
||||
next();
|
||||
},
|
||||
zoosRouter
|
||||
);
|
||||
|
||||
app.use(
|
||||
"/api/sales",
|
||||
express.raw({ type: "*/*", limit: "2mb" }),
|
||||
(req, res, next) => {
|
||||
req.bodyRaw =
|
||||
req.body && Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
||||
try {
|
||||
req.body = req.bodyRaw ? JSON.parse(req.bodyRaw) : {};
|
||||
} catch {
|
||||
req.body = {};
|
||||
}
|
||||
next();
|
||||
},
|
||||
salesRouter
|
||||
);
|
||||
|
||||
app.use(
|
||||
"/api/auth",
|
||||
express.raw({ type: "*/*", limit: "10kb" }),
|
||||
(req, res, next) => {
|
||||
req.bodyRaw =
|
||||
req.body && Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
||||
try {
|
||||
req.body = req.bodyRaw ? JSON.parse(req.bodyRaw) : {};
|
||||
} catch {
|
||||
req.body = {};
|
||||
}
|
||||
next();
|
||||
}
|
||||
);
|
||||
app.post("/api/auth/register", (req, res, next) => {
|
||||
const publicKey = req.headers["x-public-key"];
|
||||
const pseudo = req.body?.pseudo?.trim();
|
||||
if (!publicKey || !pseudo || pseudo.length < 2) {
|
||||
res.status(400).json({
|
||||
error: "pseudo required (min 2 chars) and X-Public-Key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
createAccount(publicKey, pseudo)
|
||||
.then((account) => {
|
||||
res.status(201).json({ id: account.id, pseudo: account.pseudo });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[auth/register]", err?.message ?? err);
|
||||
if (err.code === "23505") {
|
||||
res.status(409).json({ error: "pseudo or publicKey already used" });
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use(express.static(webDir));
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(webDir, "index.html"));
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error("[api]", err?.message ?? err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
app.listen(port, () => {
|
||||
console.warn(`builazoo API listening on port ${port}`);
|
||||
startBotTickInterval();
|
||||
});
|
||||
34
server/migrations/001_sale_listings.sql
Normal file
34
server/migrations/001_sale_listings.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- Phase 10: sale listings and bids (enchères)
|
||||
-- Run after schema.sql (e.g. psql -d builazoo -f server/migrations/001_sale_listings.sql)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sale_listings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
seller_zoo_id UUID NOT NULL REFERENCES zoos(id) ON DELETE CASCADE,
|
||||
animal_id TEXT NOT NULL,
|
||||
is_baby BOOLEAN NOT NULL,
|
||||
initial_price INTEGER NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'sold', 'expired', 'rejected')),
|
||||
best_bid_amount INTEGER,
|
||||
best_bidder_zoo_id UUID REFERENCES zoos(id) ON DELETE SET NULL,
|
||||
sold_at TIMESTAMPTZ,
|
||||
reproduction_score_at_sale NUMERIC,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sale_listings_seller ON sale_listings(seller_zoo_id);
|
||||
CREATE INDEX IF NOT EXISTS sale_listings_status ON sale_listings(status);
|
||||
CREATE INDEX IF NOT EXISTS sale_listings_end_at ON sale_listings(end_at);
|
||||
CREATE INDEX IF NOT EXISTS sale_listings_best_bidder ON sale_listings(best_bidder_zoo_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sale_bids (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
listing_id UUID NOT NULL REFERENCES sale_listings(id) ON DELETE CASCADE,
|
||||
bidder_zoo_id UUID NOT NULL REFERENCES zoos(id) ON DELETE CASCADE,
|
||||
amount INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(listing_id, bidder_zoo_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sale_bids_listing ON sale_bids(listing_id);
|
||||
12
server/migrations/002_sale_listings_validated_at.sql
Normal file
12
server/migrations/002_sale_listings_validated_at.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- BLOC 2: Deferred validation (10 min) for sales. Run after 001_sale_listings.sql.
|
||||
-- Adds validated_at: when accept is called, sold_at = now(), validated_at = now() + 10 minutes.
|
||||
-- Coins are transferred only when validated_at <= now() (processed by processValidatedSales).
|
||||
-- Buyer can deliver only after status = 'validated'.
|
||||
|
||||
ALTER TABLE sale_listings ADD COLUMN IF NOT EXISTS validated_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE sale_listings DROP CONSTRAINT IF EXISTS sale_listings_status_check;
|
||||
ALTER TABLE sale_listings ADD CONSTRAINT sale_listings_status_check
|
||||
CHECK (status IN ('active', 'sold', 'expired', 'rejected', 'validated'));
|
||||
|
||||
COMMENT ON COLUMN sale_listings.validated_at IS 'When the sale becomes validated (coins transfer allowed). Set at accept to now() + 10 minutes; after processValidatedSales runs, status becomes validated.';
|
||||
1015
server/package-lock.json
generated
Normal file
1015
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
server/package.json
Normal file
16
server/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "builazoo-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Build a Zoo web API (PostgreSQL, auth by keypair)",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.0",
|
||||
"pg": "^8.13.0"
|
||||
}
|
||||
}
|
||||
228
server/routes/sales.js
Normal file
228
server/routes/sales.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import express from "express";
|
||||
import {
|
||||
getAccountByPublicKey,
|
||||
getZooByAccountId,
|
||||
getSalesForZoo,
|
||||
getActiveSaleListings,
|
||||
createSaleListing,
|
||||
placeBid,
|
||||
acceptSale,
|
||||
rejectSale,
|
||||
markSaleDelivered,
|
||||
expireSaleListings,
|
||||
processValidatedSales,
|
||||
} from "../db.js";
|
||||
import { verifySignature, buildSignMessage, hashBody } from "../auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
|
||||
|
||||
function requireSignature() {
|
||||
return (req, res, next) => {
|
||||
const publicKey = req.headers["x-public-key"];
|
||||
const signature = req.headers["x-signature"];
|
||||
const timestamp = req.headers["x-timestamp"];
|
||||
if (!publicKey || !signature || !timestamp) {
|
||||
res.status(401).json({ error: "Missing X-Public-Key, X-Signature, or X-Timestamp" });
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const ts = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
|
||||
res.status(401).json({ error: "Invalid or expired timestamp" });
|
||||
return;
|
||||
}
|
||||
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
|
||||
const bodyHash = hashBody(body);
|
||||
const path = req.originalUrl || req.baseUrl + req.path || req.path;
|
||||
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
|
||||
if (!verifySignature(publicKey, signature, message)) {
|
||||
res.status(401).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
getAccountByPublicKey(publicKey).then((account) => {
|
||||
if (!account) {
|
||||
res.status(401).json({ error: "Unknown account" });
|
||||
return;
|
||||
}
|
||||
req.account = account;
|
||||
next();
|
||||
}).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
function optionalSignature() {
|
||||
return (req, res, next) => {
|
||||
const publicKey = req.headers["x-public-key"];
|
||||
const signature = req.headers["x-signature"];
|
||||
const timestamp = req.headers["x-timestamp"];
|
||||
if (!publicKey || !signature || !timestamp) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const ts = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
|
||||
const bodyHash = hashBody(body);
|
||||
const path = req.originalUrl || req.baseUrl + req.path || req.path;
|
||||
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
|
||||
if (!verifySignature(publicKey, signature, message)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
getAccountByPublicKey(publicKey).then((account) => {
|
||||
req.account = account ?? undefined;
|
||||
next();
|
||||
}).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only. */
|
||||
router.get("/", optionalSignature(), async (req, res, next) => {
|
||||
try {
|
||||
await expireSaleListings();
|
||||
if (req.account) {
|
||||
await processValidatedSales();
|
||||
const zoo = await getZooByAccountId(req.account.id);
|
||||
if (!zoo) {
|
||||
res.json({ asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() });
|
||||
return;
|
||||
}
|
||||
const data = await getSalesForZoo(zoo.id);
|
||||
res.json(data);
|
||||
return;
|
||||
}
|
||||
const active = await getActiveSaleListings();
|
||||
res.json({ active });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/sales — create listing (auth). Body: { animalId, isBaby, price, endAt, reproductionScoreAtSale? }. */
|
||||
router.post("/", requireSignature(), async (req, res, next) => {
|
||||
try {
|
||||
const zoo = await getZooByAccountId(req.account.id);
|
||||
if (!zoo) {
|
||||
res.status(404).json({ error: "No zoo for this account" });
|
||||
return;
|
||||
}
|
||||
const { animalId, isBaby, price, endAt, reproductionScoreAtSale } = req.body ?? {};
|
||||
if (typeof animalId !== "string" || !animalId.trim()) {
|
||||
res.status(400).json({ error: "animalId required" });
|
||||
return;
|
||||
}
|
||||
if (typeof isBaby !== "boolean") {
|
||||
res.status(400).json({ error: "isBaby boolean required" });
|
||||
return;
|
||||
}
|
||||
const initialPrice = Number(price);
|
||||
if (!Number.isFinite(initialPrice) || initialPrice < 0) {
|
||||
res.status(400).json({ error: "price must be a non-negative number" });
|
||||
return;
|
||||
}
|
||||
const endAtDate = endAt ? new Date(endAt) : null;
|
||||
if (!endAtDate || Number.isNaN(endAtDate.getTime())) {
|
||||
res.status(400).json({ error: "endAt required (ISO date string)" });
|
||||
return;
|
||||
}
|
||||
const { id } = await createSaleListing({
|
||||
sellerZooId: zoo.id,
|
||||
animalId: animalId.trim(),
|
||||
isBaby,
|
||||
initialPrice,
|
||||
endAt: endAtDate.toISOString(),
|
||||
reproductionScoreAtSale: reproductionScoreAtSale != null ? Number(reproductionScoreAtSale) : undefined,
|
||||
});
|
||||
res.status(201).json({ id });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/sales/:id/bid — place bid (auth). Body: { amount }. */
|
||||
router.post("/:id/bid", requireSignature(), async (req, res, next) => {
|
||||
try {
|
||||
const zoo = await getZooByAccountId(req.account.id);
|
||||
if (!zoo) {
|
||||
res.status(404).json({ error: "No zoo for this account" });
|
||||
return;
|
||||
}
|
||||
const amount = Number(req.body?.amount);
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
res.status(400).json({ error: "amount must be a non-negative number" });
|
||||
return;
|
||||
}
|
||||
const result = await placeBid(req.params.id, zoo.id, amount);
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.reason ?? "Bid failed" });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/sales/:id/accept — seller accepts best bid (auth). */
|
||||
router.post("/:id/accept", requireSignature(), async (req, res, next) => {
|
||||
try {
|
||||
const zoo = await getZooByAccountId(req.account.id);
|
||||
if (!zoo) {
|
||||
res.status(404).json({ error: "No zoo for this account" });
|
||||
return;
|
||||
}
|
||||
const result = await acceptSale(req.params.id, zoo.id);
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.reason ?? "Accept failed" });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/sales/:id/reject — seller rejects current best bid (auth). */
|
||||
router.post("/:id/reject", requireSignature(), async (req, res, next) => {
|
||||
try {
|
||||
const zoo = await getZooByAccountId(req.account.id);
|
||||
if (!zoo) {
|
||||
res.status(404).json({ error: "No zoo for this account" });
|
||||
return;
|
||||
}
|
||||
const result = await rejectSale(req.params.id, zoo.id);
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.reason ?? "Reject failed" });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/sales/:id/deliver — buyer marks as delivered (auth). */
|
||||
router.post("/:id/deliver", requireSignature(), async (req, res, next) => {
|
||||
try {
|
||||
const zoo = await getZooByAccountId(req.account.id);
|
||||
if (!zoo) {
|
||||
res.status(404).json({ error: "No zoo for this account" });
|
||||
return;
|
||||
}
|
||||
const result = await markSaleDelivered(req.params.id, zoo.id);
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.reason ?? "Deliver failed" });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
143
server/routes/zoos.js
Normal file
143
server/routes/zoos.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import express from "express";
|
||||
import {
|
||||
getAccountByPublicKey,
|
||||
getZooByAccountId,
|
||||
createZoo,
|
||||
getMapParams,
|
||||
getAllZoos,
|
||||
countPlayerZoos,
|
||||
createBotZoo,
|
||||
updateLastSeen,
|
||||
updateZooGameState,
|
||||
} from "../db.js";
|
||||
import { verifySignature, buildSignMessage, hashBody } from "../auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
|
||||
|
||||
function requireSignature() {
|
||||
return (req, res, next) => {
|
||||
const publicKey = req.headers["x-public-key"];
|
||||
const signature = req.headers["x-signature"];
|
||||
const timestamp = req.headers["x-timestamp"];
|
||||
if (!publicKey || !signature || !timestamp) {
|
||||
res.status(401).json({ error: "Missing X-Public-Key, X-Signature, or X-Timestamp" });
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const ts = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
|
||||
res.status(401).json({ error: "Invalid or expired timestamp" });
|
||||
return;
|
||||
}
|
||||
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
|
||||
const bodyHash = hashBody(body);
|
||||
const path = req.originalUrl || req.baseUrl + req.path || req.path;
|
||||
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
|
||||
if (!verifySignature(publicKey, signature, message)) {
|
||||
res.status(401).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
getAccountByPublicKey(publicKey).then((account) => {
|
||||
if (!account) {
|
||||
res.status(401).json({ error: "Unknown account" });
|
||||
return;
|
||||
}
|
||||
req.account = account;
|
||||
next();
|
||||
}).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/zoos — list zoos for map (no auth). Ensures minZoos with bots. */
|
||||
router.get("/", async (req, res, next) => {
|
||||
try {
|
||||
const params = await getMapParams();
|
||||
let zoos = await getAllZoos();
|
||||
await countPlayerZoos();
|
||||
const need = Math.max(0, params.minZoos - zoos.length);
|
||||
for (let i = 0; i < need; i++) {
|
||||
const x = 10 + Math.random() * 80;
|
||||
const y = 10 + Math.random() * 80;
|
||||
const weights = { Basic: 1 + Math.floor(Math.random() * 2), Ocean: Math.floor(Math.random() * 2), Mountain: Math.floor(Math.random() * 2) };
|
||||
await createBotZoo(x, y, weights);
|
||||
}
|
||||
if (need > 0) zoos = await getAllZoos();
|
||||
const worldZoos = zoos.map((z) => ({
|
||||
id: z.id,
|
||||
name: z.name,
|
||||
x: z.x,
|
||||
y: z.y,
|
||||
animalWeights: z.animal_weights,
|
||||
game_state: z.game_state ?? null,
|
||||
}));
|
||||
res.json({ worldZoos, mapWidth: params.mapWidth, mapHeight: params.mapHeight });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/zoos/me — my zoo + game_state (auth). */
|
||||
router.get("/me", requireSignature(), (req, res, next) => {
|
||||
getZooByAccountId(req.account.id)
|
||||
.then((zoo) => {
|
||||
if (!zoo) {
|
||||
res.status(404).json({ error: "No zoo for this account" });
|
||||
return;
|
||||
}
|
||||
updateLastSeen(req.account.id);
|
||||
res.json({
|
||||
zooId: zoo.id,
|
||||
name: zoo.name,
|
||||
x: zoo.x,
|
||||
y: zoo.y,
|
||||
game_state: zoo.game_state,
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
/** PATCH /api/zoos/me — update game_state (auth). Body: { game_state }. */
|
||||
router.patch("/me", requireSignature(), (req, res, next) => {
|
||||
getZooByAccountId(req.account.id)
|
||||
.then((zoo) => {
|
||||
if (!zoo) {
|
||||
res.status(404).json({ error: "No zoo for this account" });
|
||||
return;
|
||||
}
|
||||
const game_state = req.body?.game_state;
|
||||
if (game_state === null || game_state === undefined || typeof game_state !== "object") {
|
||||
res.status(400).json({ error: "game_state object required" });
|
||||
return;
|
||||
}
|
||||
return updateZooGameState(zoo.id, game_state).then(() => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
/** POST /api/zoos/me — create my zoo (auth). Body: { name?, game_state }. */
|
||||
router.post("/me", requireSignature(), (req, res, next) => {
|
||||
getZooByAccountId(req.account.id)
|
||||
.then((existing) => {
|
||||
if (existing) {
|
||||
res.status(409).json({ error: "Zoo already exists" });
|
||||
return;
|
||||
}
|
||||
const name = req.body?.name?.trim() || req.account.pseudo;
|
||||
const game_state = req.body?.game_state;
|
||||
if (game_state === null || game_state === undefined || typeof game_state !== "object") {
|
||||
res.status(400).json({ error: "game_state object required" });
|
||||
return;
|
||||
}
|
||||
const x = 25 + Math.random() * 50;
|
||||
const y = 25 + Math.random() * 50;
|
||||
return createZoo({ accountId: req.account.id, name, x, y, gameState: game_state }).then(({ id }) => {
|
||||
res.status(201).json({ zooId: id, name, x, y });
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
export default router;
|
||||
36
server/schema.sql
Normal file
36
server/schema.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- Build a Zoo web: accounts and zoos
|
||||
-- Run once against your PostgreSQL (e.g. psql -f server/schema.sql)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
public_key TEXT UNIQUE NOT NULL,
|
||||
pseudo TEXT UNIQUE NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zoos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
x NUMERIC NOT NULL,
|
||||
y NUMERIC NOT NULL,
|
||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
||||
animal_weights JSONB NOT NULL DEFAULT '{}',
|
||||
game_state JSONB,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(account_id) -- only one zoo per account; bots have account_id NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS zoos_account_id ON zoos(account_id);
|
||||
CREATE INDEX IF NOT EXISTS zoos_is_bot ON zoos(is_bot);
|
||||
|
||||
-- One row: map dimensions and min zoos for density
|
||||
CREATE TABLE IF NOT EXISTS map_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO map_config (key, value) VALUES
|
||||
('params', '{"mapWidth": 100, "mapHeight": 100, "minZoos": 5}')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
Reference in New Issue
Block a user