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} */ 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} 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>} */ 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} */ 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} */ 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} 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>} */ 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} */ 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} 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>} */ 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, asBuyerUndelivered: Array, active: Array }>} */ 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} 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} 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 };