**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
490 lines
18 KiB
JavaScript
490 lines
18 KiB
JavaScript
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 };
|