/** * API client: signed requests (Ed25519), load/save zoo state and world zoos. */ import { getOrCreateKeyPair, signMessage } from "./auth-client.js"; /** * @returns {string} API base URL (from ?api=, window, or localStorage) */ export function getApiBase() { if (typeof window === "undefined") return ""; let base = ""; if (window.BUILAZOO_API_URL !== undefined && window.BUILAZOO_API_URL !== null && window.BUILAZOO_API_URL !== "") { base = String(window.BUILAZOO_API_URL); } else { try { const stored = localStorage.getItem("builazoo_api_url"); if (stored && stored.trim() !== "") base = stored.trim(); } catch (_) { // ignore localStorage access errors } } return base ? base.replace(/\/+$/, "") : ""; } /** * Persist API base URL so it is used on next load (getApiBase reads it from localStorage). * @param {string} url */ export function setApiBaseUrl(url) { const raw = url ? String(url).trim() : ""; const trimmed = raw ? raw.replace(/\/+$/, "") : ""; if (typeof window !== "undefined") window.BUILAZOO_API_URL = trimmed || undefined; try { if (trimmed) localStorage.setItem("builazoo_api_url", trimmed); else localStorage.removeItem("builazoo_api_url"); } catch (_) { // ignore localStorage errors } } /** * @param {string} body * @returns {Promise} hex */ async function hashBody(body) { const enc = new TextEncoder().encode(body || ""); const buf = await crypto.subtle.digest("SHA-256", enc); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } /** * @param {string} method * @param {string} path * @param {string} [body] * @returns {Promise} */ export async function signedFetch(method, path, body) { const base = getApiBase(); if (!base) throw new Error("No API base URL"); const url = base + path; const timestamp = new Date().toISOString(); const bodyStr = body !== null && body !== undefined ? (typeof body === "string" ? body : JSON.stringify(body)) : ""; const bodyHash = await hashBody(bodyStr); const message = `${method}\n${path}\n${timestamp}\n${bodyHash}`; const keys = await getOrCreateKeyPair(); if (!keys) throw new Error("No keypair"); const signature = await signMessage(keys.privateKey, message); const headers = { "Content-Type": "application/json", "X-Public-Key": keys.publicKeyBase64, "X-Signature": signature, "X-Timestamp": timestamp, }; const init = { method, headers }; if (bodyStr) init.body = bodyStr; return fetch(url, init); } /** * @returns {Promise<{ worldZoos: Array<{ id: string, name: string, x: number, y: number, animalWeights: object }>, mapWidth: number, mapHeight: number }>} */ export async function loadZoos() { const base = getApiBase(); if (!base) return { worldZoos: [], mapWidth: 100, mapHeight: 100 }; const res = await fetch(base + "/api/zoos"); if (!res.ok) throw new Error(`loadZoos ${res.status}`); const data = await res.json(); return { worldZoos: data.worldZoos || [], mapWidth: data.mapWidth ?? 100, mapHeight: data.mapHeight ?? 100, }; } /** * Load current user's zoo. Distinguishes 401 (account unknown → register) from 404 (no zoo → create zoo). * @returns {Promise<{ ok: true, data: { zooId: string, name: string, x: number, y: number, game_state: object | null } } | { status: 401 } | { status: 404 }>} */ export async function loadMyZoo() { const base = getApiBase(); if (!base) return { status: 404 }; const res = await signedFetch("GET", "/api/zoos/me"); if (res.status === 401) return { status: 401 }; if (res.status === 404) return { status: 404 }; if (!res.ok) throw new Error(`loadMyZoo ${res.status}`); const data = await res.json(); return { ok: true, data }; } /** * @param {object} gameState * @returns {Promise} */ export async function saveMyZoo(gameState) { const base = getApiBase(); if (!base) return; const res = await signedFetch("PATCH", "/api/zoos/me", { game_state: gameState }); if (!res.ok) throw new Error(`saveMyZoo ${res.status}`); } /** * @param {string} pseudo * @returns {Promise<{ id: string, pseudo: string }>} */ export async function register(pseudo) { const base = getApiBase(); if (!base) throw new Error("No API base URL"); const keys = await getOrCreateKeyPair(); if (!keys) throw new Error("No keypair"); const res = await fetch(base + "/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json", "X-Public-Key": keys.publicKeyBase64, }, body: JSON.stringify({ pseudo: pseudo.trim() }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `register ${res.status}`); } return res.json(); } /** * @param {string} name * @param {object} gameState * @returns {Promise<{ zooId: string, name: string, x: number, y: number }>} */ export async function createMyZoo(name, gameState) { const res = await signedFetch("POST", "/api/zoos/me", { name, game_state: gameState }); if (!res.ok) throw new Error(`createMyZoo ${res.status}`); return res.json(); } /** * Fetch sales: with auth returns { asSeller, asBuyerUndelivered, active }; without auth only { active }. * @returns {Promise<{ asSeller?: Array, asBuyerUndelivered?: Array, active: Array }>} */ export async function getSales() { const base = getApiBase(); if (!base) return { active: [] }; const res = await signedFetch("GET", "/api/sales"); if (!res.ok) throw new Error(`getSales ${res.status}`); return res.json(); } /** * Map a server sale listing (asSeller item) to client SaleListing shape. * @param {{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: string, status?: string, best_bid_amount?: number, best_bidder_zoo_id?: string, sold_at?: string, validated_at?: string | null, reproduction_score_at_sale?: number }} s * @returns {import("./types.js").SaleListing} */ export function mapServerListingToClient(s) { return { id: s.id, zooId: s.seller_zoo_id, animalId: s.animal_id, isBaby: s.is_baby, price: s.initial_price, endAt: Math.floor(new Date(s.end_at).getTime() / 1000), status: s.status, bestBidAmount: s.best_bid_amount, bestBidderZooId: s.best_bidder_zoo_id, validatedAt: s.validated_at ? Math.floor(new Date(s.validated_at).getTime() / 1000) : null, reproductionScoreAtSale: s.reproduction_score_at_sale, serverId: s.id, }; } /** * Create a sale listing. Body: animalId, isBaby, price, endAt, reproductionScoreAtSale?. * @param {{ animalId: string, isBaby: boolean, price: number, endAt: string, reproductionScoreAtSale?: number }} payload * @returns {Promise<{ id: string }>} */ export async function createSale(payload) { const res = await signedFetch("POST", "/api/sales", payload); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `createSale ${res.status}`); } return res.json(); } /** * Place a bid on a listing. * @param {string} listingId * @param {number} amount * @returns {Promise} */ export async function placeBid(listingId, amount) { const res = await signedFetch("POST", `/api/sales/${listingId}/bid`, { amount }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `placeBid ${res.status}`); } } /** * Seller accepts the best bid. * @param {string} listingId * @returns {Promise} */ export async function acceptSale(listingId) { const res = await signedFetch("POST", `/api/sales/${listingId}/accept`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `acceptSale ${res.status}`); } } /** * Seller rejects the current best bid. * @param {string} listingId * @returns {Promise} */ export async function rejectSale(listingId) { const res = await signedFetch("POST", `/api/sales/${listingId}/reject`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `rejectSale ${res.status}`); } } /** * Buyer marks a won listing as delivered (after adding baby/animal to zoo). * @param {string} listingId * @returns {Promise} */ export async function deliverSale(listingId) { const res = await signedFetch("POST", `/api/sales/${listingId}/deliver`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `deliverSale ${res.status}`); } }