**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
259 lines
8.4 KiB
JavaScript
259 lines
8.4 KiB
JavaScript
/**
|
|
* 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<string>} 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<Response>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<object>, asBuyerUndelivered?: Array<object>, active: Array<object> }>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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}`);
|
|
}
|
|
}
|