Lint: fix errors and remove unused variables

**Motivations:**
- Ensure lint config is not degraded and fix all lint errors for pousse workflow.

**Root causes:**
- Unused variables kept with _ prefix instead of removed (_row, _questReward, _i).
- getAnimalBlockOrigin had 5 parameters (max 4).
- use of continue statement (no-continue rule).

**Correctifs:**
- ESLint config verified; no eslint-disable in codebase.
- Removed unused variable _row (biome-rules); removed dead function _questReward (quests); removed unused map param _i (state.js).
- getAnimalBlockOrigin refactored to 4 params (pos object instead of x, y).
- Replaced continue with if (cell) block in normalizeLoadedCells (state.js).
- JSDoc param names aligned with _height, _y (biome-rules).

**Evolutions:**
- (none)

**Pages affectées:**
- web/js/biome-rules.js
- web/js/quests.js
- web/js/state.js
- web/js/placement.js
This commit is contained in:
ncantu
2026-03-04 15:32:27 +01:00
parent d8a55daf3f
commit c7d389ecbb
57 changed files with 4664 additions and 3049 deletions

227
server/db-core.js Normal file
View File

@@ -0,0 +1,227 @@
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]
);
}
export { pool };

267
server/db-sales.js Normal file
View File

@@ -0,0 +1,267 @@
import { pool, getZooById, updateZooGameState } from "./db-core.js";
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),
};
}
/**
* @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 };
}
/**
* @param {object} listing
* @returns {Promise<{ ok: true, buyerZoo: object, sellerZoo: object } | { ok: false, reason: string }>}
*/
async function validateBuyerAndSeller(listing) {
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(listing.seller_zoo_id);
if (!buyerZoo || !buyerZoo.game_state) return { ok: false, reason: "BuyerStateMissing" };
if (!sellerZoo || !sellerZoo.game_state) return { ok: false, reason: "SellerStateMissing" };
const buyerCoins = Number(buyerZoo.game_state.coins ?? 0);
if (buyerCoins < amount) return { ok: false, reason: "BuyerInsufficientCoins" };
return { ok: true, buyerZoo, sellerZoo };
}
/**
* 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 parties = await validateBuyerAndSeller(validated.listing);
if (!parties.ok) return { ok: false, reason: parties.reason };
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 };
}
/**
* 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 };
}
/**
* 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;
}

View File

@@ -1,489 +1,29 @@
import pg from "pg";
import parse from "pg-connection-string";
import { createInitialBotState } from "./bot-state.js";
export {
pool,
getMapParams,
getAccountByPublicKey,
createAccount,
updateLastSeen,
getZooByAccountId,
getAllZoos,
createZoo,
getZooById,
updateZooGameState,
countPlayerZoos,
createBotZoo,
getBotZoosForTick,
updateBotZooState,
} from "./db-core.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 };
export {
createSaleListing,
getSaleListingById,
getActiveSaleListings,
getSalesForZoo,
placeBid,
acceptSale,
processValidatedSales,
rejectSale,
markSaleDelivered,
expireSaleListings,
} from "./db-sales.js";

View File

@@ -17,6 +17,35 @@ import { verifySignature, buildSignMessage, hashBody } from "../auth.js";
const router = express.Router();
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
/** @param {string} timestamp
* @returns {boolean}
*/
function isTimestampValid(timestamp) {
const now = Date.now();
const ts = new Date(timestamp).getTime();
return !Number.isNaN(ts) && Math.abs(now - ts) <= TIMESTAMP_TOLERANCE_MS;
}
/** @param {import("express").Request} req
* @returns {string}
*/
function getBodyForSignature(req) {
return req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
}
/** @param {import("express").Request} req
* @param {string} publicKey
* @param {string} signature
* @param {string} timestamp
* @returns {boolean}
*/
function isSignatureValid(req, publicKey, signature, timestamp) {
const bodyHash = hashBody(getBodyForSignature(req));
const path = req.originalUrl || req.baseUrl + req.path || req.path;
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
return verifySignature(publicKey, signature, message);
}
function requireSignature() {
return (req, res, next) => {
const publicKey = req.headers["x-public-key"];
@@ -26,17 +55,11 @@ function requireSignature() {
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) {
if (!isTimestampValid(timestamp)) {
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)) {
if (!isSignatureValid(req, publicKey, signature, timestamp)) {
res.status(401).json({ error: "Invalid signature" });
return;
}
@@ -60,17 +83,7 @@ function optionalSignature() {
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)) {
if (!isTimestampValid(timestamp) || !isSignatureValid(req, publicKey, signature, timestamp)) {
next();
return;
}
@@ -81,28 +94,62 @@ function optionalSignature() {
};
}
/** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only. */
/** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only.
* @param {import("express").Request} req
* @returns {Promise<{ active: Array<object> } | { asSeller: Array<object>, asBuyerUndelivered: Array<object>, active: Array<object> }>}
*/
async function getSalesResponse(req) {
await expireSaleListings();
if (!req.account) {
const active = await getActiveSaleListings();
return { active };
}
await processValidatedSales();
const zoo = await getZooByAccountId(req.account.id);
if (!zoo) {
return { asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() };
}
return getSalesForZoo(zoo.id);
}
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 });
const data = await getSalesResponse(req);
res.json(data);
} catch (e) {
next(e);
}
});
/**
* @param {unknown} body
* @returns {{ status: number, error: string } | null}
*/
function validateCreateListingBody(body) {
const { animalId, isBaby, price, endAt } = body ?? {};
if (typeof animalId !== "string" || !animalId.trim()) return { status: 400, error: "animalId required" };
if (typeof isBaby !== "boolean") return { status: 400, error: "isBaby boolean required" };
const initialPrice = Number(price);
if (!Number.isFinite(initialPrice) || initialPrice < 0) return { status: 400, error: "price must be a non-negative number" };
const endAtDate = endAt ? new Date(endAt) : null;
if (!endAtDate || Number.isNaN(endAtDate.getTime())) return { status: 400, error: "endAt required (ISO date string)" };
return null;
}
/**
* @param {unknown} body
* @returns {{ ok: true, animalId: string, isBaby: boolean, initialPrice: number, endAtDate: Date, reproductionScoreAtSale?: number } | { ok: false, status: number, error: string }}
*/
function parseCreateListingBody(body) {
const err = validateCreateListingBody(body);
if (err) return { ok: false, status: err.status, error: err.error };
const { animalId, isBaby, price, endAt, reproductionScoreAtSale } = body ?? {};
const endAtDate = endAt ? new Date(endAt) : null;
const initialPrice = Number(price);
const repScore = reproductionScoreAtSale !== null && reproductionScoreAtSale !== undefined ? Number(reproductionScoreAtSale) : undefined;
return { ok: true, animalId: String(animalId).trim(), isBaby, initialPrice, endAtDate: /** @type {Date} */ (endAtDate), reproductionScoreAtSale: repScore };
}
/** POST /api/sales — create listing (auth). Body: { animalId, isBaby, price, endAt, reproductionScoreAtSale? }. */
router.post("/", requireSignature(), async (req, res, next) => {
try {
@@ -111,32 +158,18 @@ router.post("/", requireSignature(), async (req, res, next) => {
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)" });
const parsed = parseCreateListingBody(req.body);
if (!parsed.ok) {
res.status(parsed.status).json({ error: parsed.error });
return;
}
const { id } = await createSaleListing({
sellerZooId: zoo.id,
animalId: animalId.trim(),
isBaby,
initialPrice,
endAt: endAtDate.toISOString(),
reproductionScoreAtSale: reproductionScoreAtSale != null ? Number(reproductionScoreAtSale) : undefined,
animalId: parsed.animalId,
isBaby: parsed.isBaby,
initialPrice: parsed.initialPrice,
endAt: parsed.endAtDate.toISOString(),
reproductionScoreAtSale: parsed.reproductionScoreAtSale,
});
res.status(201).json({ id });
} catch (e) {

View File

@@ -15,6 +15,23 @@ import { verifySignature, buildSignMessage, hashBody } from "../auth.js";
const router = express.Router();
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
function isTimestampValid(timestamp) {
const now = Date.now();
const ts = new Date(timestamp).getTime();
return !Number.isNaN(ts) && Math.abs(now - ts) <= TIMESTAMP_TOLERANCE_MS;
}
function getBodyForSignature(req) {
return req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
}
function isSignatureValid(req, publicKey, signature, timestamp) {
const bodyHash = hashBody(getBodyForSignature(req));
const path = req.originalUrl || req.baseUrl + req.path || req.path;
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
return verifySignature(publicKey, signature, message);
}
function requireSignature() {
return (req, res, next) => {
const publicKey = req.headers["x-public-key"];
@@ -24,17 +41,11 @@ function requireSignature() {
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) {
if (!isTimestampValid(timestamp)) {
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)) {
if (!isSignatureValid(req, publicKey, signature, timestamp)) {
res.status(401).json({ error: "Invalid signature" });
return;
}
@@ -49,20 +60,31 @@ function requireSignature() {
};
}
/** @returns {Promise<{ zoos: Array<object>, mapWidth: number, mapHeight: number }>}
*/
async function getZoosForMap() {
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();
return {
zoos,
mapWidth: params.mapWidth,
mapHeight: params.mapHeight,
};
}
/** 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 { zoos, mapWidth, mapHeight } = await getZoosForMap();
const worldZoos = zoos.map((z) => ({
id: z.id,
name: z.name,
@@ -71,7 +93,7 @@ router.get("/", async (req, res, next) => {
animalWeights: z.animal_weights,
game_state: z.game_state ?? null,
}));
res.json({ worldZoos, mapWidth: params.mapWidth, mapHeight: params.mapHeight });
res.json({ worldZoos, mapWidth, mapHeight });
} catch (e) {
next(e);
}