commit e031c9a1d2d1aae072dcbf1d318c6f9a109aa0c3 Author: Nicolas Cantu Date: Tue Mar 3 22:24:17 2026 +0100 Initial commit **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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e1ccb5 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and set values. .env is not committed. +DATABASE_URL=postgres://USER:PASSWORD@localhost/builazoo +PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d11b9b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.env +.DS_Store +dist/ +build/ +.cursor/ +terminals/ +agent-transcripts/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..40f2090 --- /dev/null +++ b/default.project.json @@ -0,0 +1,19 @@ +{ + "name": "builazoo", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "Shared": { + "$path": "src/ReplicatedStorage/Shared" + } + }, + "ServerScriptService": { + "$path": "src/ServerScriptService" + }, + "StarterPlayer": { + "StarterPlayerScripts": { + "$path": "src/StarterPlayer/StarterPlayerScripts" + } + } + } +} diff --git a/docs/bdd-comptes.md b/docs/bdd-comptes.md new file mode 100644 index 0000000..4a5bc2d --- /dev/null +++ b/docs/bdd-comptes.md @@ -0,0 +1,75 @@ +# Base de données et comptes + +Utilisation de PostgreSQL pour persister les zoos et les comptes joueurs, et du client pour se connecter à l’API. + +--- + +## 1. Schéma PostgreSQL + +Fichier : `server/schema.sql`. + +Créer la base et exécuter le schéma une fois : + +```bash +createdb builazoo +psql -d builazoo -f server/schema.sql +``` + +Ou avec une URL complète : + +```bash +psql "$DATABASE_URL" -f server/schema.sql +``` + +**Tables :** + +- **accounts** : `id` (UUID), `public_key` (texte unique), `pseudo` (texte unique), `created_at`, `last_seen_at`. Un compte = une clé publique + pseudo (pas de mot de passe). +- **zoos** : `id`, `account_id` (FK vers accounts, NULL pour les bots), `name`, `x`, `y`, `is_bot`, `animal_weights` (JSONB), `game_state` (JSONB), `updated_at`. Un compte a au plus un zoo (contrainte UNIQUE sur `account_id`). +- **map_config** : une ligne `params` avec `mapWidth`, `mapHeight`, `minZoos` pour la densité de zoos et les bots. + +--- + +## 2. Variables d’environnement serveur + +- **DATABASE_URL** : URL de connexion PostgreSQL (ex. `postgres://user:pass@localhost:5442/builazoo`). **Port 5442** si PostgreSQL écoute sur ce port sur la machine. Par défaut `postgres://localhost/builazoo` (port 5432). +- **PORT** : Port HTTP du serveur (défaut 3000). + +Le serveur charge un fichier **`.env`** à la racine du projet (via `dotenv`) au démarrage. Copier `.env.example` en `.env` et renseigner les valeurs. Démarrer avec : `node server/index.js` (depuis la racine du projet ou depuis `server/`). + +--- + +## 3. Activer l’API côté client + +Trois possibilités : + +1. **Paramètre d’URL** : ouvrir l’app avec `?api=https://votre-serveur.example.com` (sans slash final). Exemple : `https://jeu.example.com/?api=https://api.example.com`. +2. **Champ « URL du serveur »** : au premier lancement sans `?api=`, l’écran propose « Jouer en local » ou « Se connecter au serveur ». Saisir l’URL du serveur et cliquer sur « Se connecter au serveur ». L’URL est enregistrée en `localStorage` (`builazoo_api_url`) et réutilisée aux prochains chargements. +3. **Injection globale** : définir `window.BUILAZOO_API_URL` avant le chargement du script (comme le fait le script dans `index.html` pour `?api=`). + +Si une URL est définie (paramètre, localStorage ou `window`), le jeu charge la liste des zoos via `GET /api/zoos`, puis le zoo du joueur via `GET /api/zoos/me` (requête signée). La sauvegarde se fait par `PATCH /api/zoos/me` avec `game_state` en JSON. + +--- + +## 4. Flux compte et zoo + +- **401 sur GET /api/zoos/me** : compte inconnu → affichage de l’écran « Créer un compte » (pseudo). Après inscription (`POST /api/auth/register` avec `X-Public-Key`), création du zoo (`POST /api/zoos/me`) puis entrée dans le jeu. +- **404 sur GET /api/zoos/me** : compte connu mais aucun zoo → création automatique d’un zoo (`POST /api/zoos/me`) avec état initial, puis entrée dans le jeu. +- **200** : zoo existant → chargement de `game_state`, mise à jour de `last_seen_at` côté serveur. + +Authentification : clé Ed25519 générée dans le navigateur, clé publique envoyée à l’inscription ; chaque requête authentifiée est signée (méthode, chemin, timestamp, hash du body). La clé privée reste en local (localStorage). + +--- + +## 5. Déploiement + +- **Backend** : héberger le serveur Node (Express) avec accès au PostgreSQL ; exposer l’URL de l’API (proxy, CORS si besoin). +- **Front** : déployer les fichiers statiques ; les utilisateurs passent par `?api=...` ou par l’écran « Se connecter au serveur » pour pointer vers l’API. +- **CORS** : le serveur utilise `cors({ origin: true, credentials: true })` ; adapter si des origines précises sont imposées. + +--- + +## 6. Références + +- Schéma et routes : `server/schema.sql`, `server/db.js`, `server/routes/zoos.js`. +- Client : `web/js/api-client.js`, `web/js/auth-client.js`, `web/js/main.js`. +- Fonctionnalités détaillées : `docs/features/carte-fixe-serveur-auth.md`, `docs/features/reste-a-implementer-carte-serveur.md`. diff --git a/docs/cahier des charges.md b/docs/cahier des charges.md new file mode 100644 index 0000000..ce0eb10 --- /dev/null +++ b/docs/cahier des charges.md @@ -0,0 +1,411 @@ +# Spécifications finales – Build a Zoo (Evo Zoo) + +Document consolidé décrivant l'état final du projet, sans les étapes intermédiaires ni le fil des évolutions. + +--- + +## 1. Périmètre et objectif + +Reconstruction, à fonctionnalités équivalentes, d'un tycoon centré sur l'achat d'œufs (via une carte du monde), l'éclosion en animaux, la production passive de monnaie, l'extension du terrain et l'amélioration des chances de rareté. Version web (navigateur), pas Roblox. Jeu multi-joueur : comptes, base de données, zoos des autres joueurs et bots visibles sur la carte. + +### Boucle de jeu + +- Achat d'œufs depuis la carte du monde (glisser l'œuf sur le camion pour acheter). +- Placement des œufs en nurserie ou sur une case vide (grille du zoo). +- Éclosion temporisée → animal selon biome et rareté (tirage pondéré). +- Revenus passifs (animaux + visiteurs + boutiques). +- Dépense de pièces : améliorer compétences (école), parcelle, camion, carte du monde, nurseries, boutiques. + +### Garanties au démarrage et au chargement + +- À la création d'un zoo (inscription) : état initial avec 200 pièces, offres déjà renouvelées avant envoi au serveur. +- **Démarrage autonome** : Le joueur commence systématiquement avec **3 couples reproducteurs d'animaux basiques** (ex. Lapins, Poules, Canards selon biome de départ) déjà placés ou dans l'inventaire. Cela garantit une reproduction immédiate et alimente le marché "bas prix" dès le début sans dépendre des enchères externes. +- Au chargement (localStorage ou API) : si pièces < 100, correction à 200 ; si liste d'offres vide, rafraîchissement des offres. Ainsi le joueur a toujours de quoi jouer. + +### Règles économie + +- Revenu par animal : revenu de base × multiplicateur mutation × multiplicateur niveau, par intervalle de temps. +- Coûts d'upgrade : progression par paliers (ex. coût de base × facteur^(niveau−1)). +- Loot œuf → animal : tirage pondéré selon le biome de la case et la table de l'œuf (15 couleurs, 5 niveaux de rareté par couleur, 75 animaux). Les niveaux de rareté sont pondérés par la suite de Fibonacci (F(1)=1, F(2)=1, F(3)=2, F(4)=3, F(5)=5) pour les revenus et la valeur de vente. Identification des animaux par couleur et rareté (ex. c0_r0 à c14_r4). Compatibilité anciennes sauvegardes : au chargement, animal inconnu → c0_r0, œuf inconnu → Color_1. +- Sécurité : pas de confiance au client pour pièces, loot, timers, placements ; limitation de la fréquence des actions ; traçabilité des transactions. + +--- + +## 2. Carte du zoo (grille) + +### Cases spéciales + +- **École** : une case (ex. (1,1)) affiche l'icône école ; niveau de compétences = max des niveaux d'écoles ; déblocage des types d'œufs selon ce niveau. Amélioration en cliquant sur la case (flèche ▲ si upgrade possible). +- **Nurserie** : œufs achetés apparaissent dans les cases nurserie (au-dessus de la zone « Agrandir zoo »), un œuf par case ; placement par clic sur une case vide ou glisser. Construction payante sur case vide (choix Nurserie ou Boutique). Le jeu commence avec une case nurserie à côté de l'école. +- **Boutique de souvenirs** : construction payante sur case vide. Les visiteurs qui passent par une boutique rapportent plus de pièces. Évolution payante pour augmenter les revenus par passage. +- **Nurserie – évolution** : payante ; éclosion plus rapide selon le niveau ; les animaux plus rares ont un temps d'éclosion plus long (multiplicateur rareté). +- **Agrandir zoo** : zone dédiée au-dessus de la zone du camion (📐 + flèche ▲ si upgrade possible). Plus d'icônes « Agrandir » ou « Agrandir la carte » à côté de l'école sur la grille. +- **Zone camion (vente)** : en bas à droite du panneau parcelle. Glisser un animal sur cette zone pour le vendre (envoi à un autre zoo, animation camion sur la carte du monde). + +### Grille et Feedbacks Visuels Environnementaux + +- Biomes par tiers de largeur : prairie (gauche), océan (milieu), montagne (droite). +- **Feedbacks visuels de terrain** (remplace les jauges) : + - **Mauvais biome** : L'herbe jaunit fortement, le sol se craquelle ou devient boueux (changement de teinte global de la case). + - **Température inadaptée** : Le sol se couvre de givre blanc (trop froid) ou émet des vapeurs rougeâtres (trop chaud). + - **Saleté** : Accumulation visible de déchets sombres ou de poussière grise si l'entretien laisse à désirer. +- Glisser-déposer : œufs et animaux déplaçables d'une case à l'autre ; image de glissement = contenu de la case (emoji + libellé). +- **Disparition des animaux** : un animal non visité par un visiteur pendant une durée configurable (ex. 5 min, `Visitor.MaxSecondsWithoutVisit`) est retiré ; la case redevient vide. + +### Visiteurs et Incidents (Gameplay d'attente) + +- Les visiteurs se déplacent sur la grille (attraction vers les animaux à forte valeur). +- **Incidents et Exigences** : Les visiteurs peuvent rencontrer des problèmes ou exprimer des exigences spécifiques (soif, poubelle pleine, banc requis, animal trop loin, envie de photo). + - **Apparition contextuelle** : Ces incidents apparaissent plus fréquemment lorsque le joueur est en phase d'attente (camion en déplacement, enchère en cours, éclosion longue) pour combler les temps morts et offrir une activité gratifiante. + - **Affichage** : Une bulle d'icône apparaît au-dessus du visiteur concerné. + - **Résolution** : Le joueur doit cliquer sur la bulle ou effectuer une action correctrice (poser un banc, nettoyer). + - **Impact** : + - Résolu : Gain immédiat d'attraction et bonus de pièces. + - Ignoré/Non résolu : Perte d'attraction et départ prématuré du visiteur. +- **Invités de luxe** : Une part des visiteurs (ex. 8 %) paie plus l'entrée et dépense plus en boutique. + +--- + +## 3. Carte du monde + +### Présentation + +- Carte fixe avec zoos en positions fixes. Le **zoom** dépend uniquement du niveau d'upgrade « Agrandir carte » (pas de molette ni de pan manuel). Grille de fond aux couleurs des biomes (prairie / océan / montagne), comme la grille du zoo. Zoos, villes et laboratoire affichés par-dessus. +- Un seul œuf visible par zoo : nom du zoo puis un slot fixe avec soit l'icône zoo (🏠), soit un œuf (type + prix) pour les autres zoos. Pour le zoo du joueur : pas d'œuf affiché, seulement nom + icône zoo. +- Achat d'œuf : glisser l'œuf depuis le slot d'un zoo (ou du laboratoire) sur le **camion** du joueur ; le camion fait l'aller-retour zoo joueur → zoo vendeur → zoo joueur ; la vitesse dépend du niveau du camion. Les œufs achetés apparaissent en nurserie sur la carte du zoo. + +### Zones sur la carte du monde + +- **Agrandir la carte** : zone au même emplacement que le camion (au-dessus), avec flèche ▲ si upgrade possible. +- **Camion** : zone pour déposer un œuf (achat) ; même zone pour l'animation de vente (animal vendu → camion vers un autre zoo). + +### Laboratoire + +- POI « Laboratoire » (🔬) sur la carte. Parfois une offre d'œuf (prix fixe, pas d'enchères) ; achat en glissant l'œuf sur le camion ou équivalent selon l'UI. + +### Villes + +- Villes affichées (icône 🏙️). Plus le zoo est proche d'une ville, plus il « attire » de visiteurs (formule d'attraction). La valeur totale des animaux du zoo augmente aussi l'attraction. + +### Stagnation + +- Zoos qui évoluent peu (aucune action d'évolution depuis un certain temps) subissent une baisse progressive du multiplicateur de visiteurs (jusqu'à un plancher, ex. 10 %). + +### Camions NPC + +- Des camions (ventes d'œufs entre zoos) sont visibles sur la carte ; ils se déplacent entre deux zoos (animation interpolée), ajoutés périodiquement par la boucle de jeu. + +### Bots + +- Les zoos bots ont les mêmes indicateurs et formules que le joueur (revenus, niveaux). Leurs décisions (achat, vente, upgrades) sont pilotées par un **profil** (fast ~70 % du coût pour agir, slow ~200 %, balanced intermédiaire). Les couleurs d'œufs achetées sont pondérées par les **zoos voisins** sur la carte (distance max configurable) et les poids propres. + +--- + +## 4. Interface et barre du haut + +### Barre du jeu (tout en haut, toute la largeur) + +- À gauche : titre « Construis un zoo » + icône d'aide « ? » (tooltip sur la ligne de statut). +- Barre d'indicateurs avec icônes uniquement ; texte (libellé) au survol (tooltip). Indicateurs : Pièces (🪙), Parcelle (📐), Case sélectionnée (📍, format « x y » sans virgule), Compétences (🎓), Visiteurs (👤), Œufs à vendre (🥚), Météo/heure (🌤️). Aucune décimale (valeurs entières). +- À droite : bouton Musique (🎵, actif / grisé si désactivé), icône Quêtes (📋, ouverture d'un menu type notification avec « Objectifs du jour »), Prestige (⭐), Recommencer (🔄), Mode automatique (✋ inactif / 🤖 actif). Le bouton de vue (zoo / monde) affiche l'icône de la vue active (🦒 ou 🗺️) et bascule au clic. + +### Contenu sous la barre + +- Pas de barre (ligne) de séparation entre la barre et le contenu. Pas d'onglets « Carte du zoo » / « Carte du monde » ni titres « Parcelles (?) » / « Carte du monde (?) ». Un seul sélecteur dans la barre : deux icônes zoo / monde pour afficher soit la grille du zoo soit la carte du monde. +- Message d'erreur : affiché au-dessus du contenu ; masqué (non visible, ne prend pas de place) lorsqu'il n'y a pas d'erreur. + +### Rafraîchissement + +- La barre du haut n'est pas reconstruite à chaque mise à jour ; seules les parties dynamiques (indicateurs, grille, carte du monde) sont rafraîchies. La vue active (zoo ou monde) reste celle affichée après un rafraîchissement. + +### Sons, musique, quêtes, météo, prestige + +- Sons distincts par action (achat, placement, éclosion, upgrade, vente, quête, erreur). Musique de fond activable (bouton 🎵) ; préférence enregistrée (ex. localStorage). +- Quêtes / objectifs journaliers : 3 quêtes par jour (placer œufs, gagner pièces, vendre animaux, améliorer tapis/parcelle) ; récompense en pièces à la complétion ; affichage en menu type notification (icône 📋). +- Météo (ensoleillé / nuageux / pluie) et cycle jour–nuit (longueur configurable) ; affichage dans la barre (ex. « Jour · Ensoleillé »). Fond de page dont la couleur ou le dégradé varie selon la phase (aube, jour, crépuscule, nuit) et la météo. +- Prestige : reset grille et progression contre bonus permanent de revenus (+15 % par niveau) ; coût minimal en pièces. Bouton ⭐ dans la barre. +- Visiteurs : sprites animés sur la grille (trajectoires uniques, attraction vers le centre pondéré par la valeur de vente des animaux) ; revenu additionnel = (nombre de visiteurs) × paiement par visiteur. + +--- + +## 5. Comptes, persistance et multijoueur + +### Accès + +- Pas de jeu en local : au démarrage, écran avec champ « URL du serveur » (pré-rempli si déjà enregistré) et bouton « Se connecter au serveur ». Après connexion, bootstrap : chargement des zoos, chargement du zoo du joueur (GET /api/zoos/me). + +### Comptes + +- Authentification par clé (pseudo sans mot de passe ; paire clé générée dans le navigateur ; requêtes signées ; clé privée stockée en local). Comptes en base (ex. table `accounts` : id, public_key, pseudo, etc.). +- **401** sur GET /api/zoos/me : compte inconnu → écran « Créer un compte » (pseudo), puis inscription et création du zoo. +- **404** : compte connu mais aucun zoo → création automatique d'un zoo avec état initial, puis entrée dans le jeu. +- **200** : chargement du `game_state` et affichage du jeu. + +### Données joueurs et bots + +- Table `zoos` : une ligne par zoo (joueur ou bot) avec id, name, position (x, y), animal_weights, game_state (JSONB), is_bot, account_id (NULL pour les bots). Les bots ont leur game_state persisté et mis à jour par un tick serveur ; le client affiche les zoos reçus de l'API (refetch périodique). + +### Mode automatique et Profils d'absence + +Le joueur peut activer un « mode automatique » (bouton dans la barre) pour maintenir son zoo en vie lors de ses absences. Il doit choisir un **profil de gestion** parmi 50 archétypes (basés sur les logiques des bots). + +**Interface de sélection des profils :** + +- L'interface ne doit pas être une simple liste déroulante. +- Elle doit proposer une sélection hiérarchique : + 1. **Choix de la Famille** (Conservateur, Éleveur, Commerçant, Expansionniste, Scientifique). + 2. **Choix de la Spécialisation** au sein de la famille choisie (ex: "Le Jardinier Prudent" vs "Le Gardien"). +- Chaque profil affiche clairement ses priorités et ses risques. + +**Liste des 50 profils disponibles (catégories principales) :** + +1. **Les Conservateurs (1-10)** : Priorité absolue à la survie. Achètent uniquement de la nourriture, ne vendent pas, ne s'étendent pas. + * *Profils :* "Le Jardinier Prudent", "L'Économe", "Le Gardien", "Le Protecteur", "Le Minimaliste", etc. +2. **Les Éleveurs (11-20)** : Priorité à la reproduction. Optimisent les placements pour les naissances, achètent des partenaires. + * *Profils :* "Le Généticien", "Le Marieur", "La Cigogne", "Le Producteur de Masse", "L'Expert en Rareté", etc. +3. **Les Commerçants (21-30)** : Priorité à l'achat/vente. Achètent bas, revendent dès qu'une marge est possible. + * *Profils :* "Le Trader", "L'Importateur", "Le Grossiste", "Le Spéculateur", "Le Négociant Rapide", etc. +4. **Les Expansionnistes (31-40)** : Priorité à l'agrandissement du terrain et des infrastructures. Investissent tout le cash dans la pierre. + * *Profils :* "Le Bâtisseur", "L'Architecte", "Le Conquérant", "Le Visionnaire", "L'Urbaniste", etc. +5. **Les Scientifiques (41-50)** : Priorité à la recherche et à l'école. Cherchent à débloquer les niveaux supérieurs avant tout. + * *Profils :* "Le Chercheur", "Le Professeur", "L'Innovateur", "Le Technologue", "Le Savant Fou", etc. + +Chaque profil dispose de paramètres spécifiques (seuil de dépense, fréquence d'action, type d'animaux préférés) qui influencent l'évolution du zoo pendant l'absence du joueur. + +--- + +## 6. Intervalles et charge + +- Boucle de jeu (revenus, météo, temps, visites, bots, éclosions, sauvegarde) : environ toutes les **5 s**. +- Sauvegarde locale et API : au plus toutes les **5 s** ; throttle côté API (pas plus d'un envoi toutes les 5 s). +- Rafraîchissement des offres (carte du monde) : au plus toutes les **8 s**. + +--- + +## 7. Éléments non implémentés / suite possible + +### Billeterie + +- Demande : une icône « boutique » au démarrage = billeterie ; les visiteurs arrivent par la billeterie, paient l'entrée une fois, repartent avant la fin de la journée, puis reviennent selon l'attraction du zoo. **Non implémenté** : pas de case billeterie ni de modèle explicite arrivée / départ / retour selon l'attraction. Suite possible : préciser le design (case dédiée, lien avec boutiques souvenir et calcul des visiteurs), puis implémenter le flux. + +### Optionnel / à trancher + +- Rafraîchissement périodique de la liste des zoos (GET /api/zoos) : déjà en place (ex. 30 s). Usage de mapWidth / mapHeight renvoyés par l'API pour adapter l'échelle de la carte au nombre de joueurs : non utilisé pour l'instant (positions en %). + +### Autres + +- Trading joueur–joueur (échanges directs). +- Événements live réels (fenêtres UTC avec œufs / récompenses limités dans le temps). +- Leveling des animaux (mécanique pour faire monter le niveau au-delà du multiplicateur actuel). +- Options de confort (vitesse du jeu x2, auto-collect distinct). +- Décorations sur la parcelle. + +--- + +## 8. Références + +- **BDD et comptes** : `docs/bdd-comptes.md` (schéma SQL, variables d'environnement, flux 401/404/200, déploiement). + +--- + +## 9. Rappel des grandes règles + +### Cartes + +- Même style avec des cases de couleurs influençant le développement des animaux en cohérence animal/milieu. +- Les cases forment le quadrillage des cartes. + +### Cases + +- Elles ont forcément une couleur (milieu eau douce, eau salée, montagne, prairie, forêt… et une température). +- Les transitions de couleurs et de températures sont douces entre les cases. +- Certains animaux prennent plusieurs cases. + +--- + +## 10. Carte du zoo – cases détaillées + +### Types de cases + +#### Centre de recherche + +- Produit des unités de recherche à dépenser pour agrandir la carte du monde. +- 7 niveaux : par palier : niveaux plus élevés, nombre de zoos plus élevé. +- Donne accès à des niveaux d'animaux et de bébés dans les autres zoos. +- 1 unité couvre 10 zoos définis maximum dans la carte (par proximité). + +#### Billeterie + +- 7 niveaux : par palier : plus cher, plus de visiteurs dans le zoo en simultané. +- Permet de vendre des entrées aux visiteurs. +- 1 unité couvre 20 visiteurs dans le zoo en simultané maximum (limite les visiteurs dans le zoo). + +#### Boutique + +- 7 niveaux : par palier : plus cher, plus de visiteurs simultanés. +- Permet de vendre des produits aux visiteurs lorsqu'ils passent par des boutiques. +- 1 unité couvre 5 visiteurs simultanés au maximum. + +#### Nurserie + +- 7 niveaux : par palier : plus rapide, meilleurs reproducteurs. +- Permet de faire naître des bébés durant une période de temps. +- 1 unité fait grandir 1 bébé maximum. + +#### Nourriture + +- 7 niveaux : par palier : plus de nourriture de meilleurs effets sur les animaux (reproduction). +- Permet de nourrir les animaux (chaque animal a une consommation / unité de temps, sinon il meurt et un doit avoir toujours bien mangé pour se reproduire). +- Permet de se reproduire. +- 1 unité couvre 5 animaux maximum. + +#### Accueil nouveaux animaux + +- 7 niveaux : par palier : plus rapide, meilleurs reproducteurs. +- Permet d'acclimater les nouveaux animaux durant une période de temps. +- 1 unité couvre 1 animal. + +#### Camion + +- 7 niveaux : par palier : plus rapide, dégrade moins le score de reproduction avec la durée du transport. +- Dans la carte du zoo : quand on déplace dessus un bébé ou un animal il sort de la carte du zoo et il est mis en vente sur la carte du monde dans une case de vente. +- Dans la carte du monde : se déplace (allers retours) entre le zoo du joueur et les autres zoos (achats/ventes), les objets sont pris en compte une fois arrivés au zoo ou retirés du zoo par le camion. +- 1 unité couvre 1 camion. + +#### Changement de milieu (couleur choisie) (payant) + +- 7 niveaux : par palier : plus détaillé, plage de température plus précise, améliore la reproduction, diminue le besoin de nourriture, allonge le temps avant de mourir dans le cas des morts. +- Dans la carte du monde : se déplace (allers retours) entre le zoo du joueur et les autres zoos (achats/ventes), les objets sont pris en compte une fois arrivés au zoo ou retirés du zoo par le camion. +- 1 unité couvre 1 camion. + +#### Changement de milieu (température choisie) + +- 7 niveaux : par palier : plus détaillé, plage de température plus précise, améliore la reproduction, diminue le besoin de nourriture, allonge le temps avant de mourir dans le cas des morts. + +#### Bébé mature + +- Fin de période de nurserie dans la nurserie pour être déplacé sur une case du zoo (devient un animal) ou sur le camion (bébé). + +#### Animal prêt + +- Fin de période d'accueil de l'animal dans l'accueil des animaux pour être déplacé sur une case du zoo ou sur le camion. + +### Règles communes aux cases + +- Certains animaux prennent plusieurs cases. +- Une couleur et température de départ. +- On peut acheter dessus (occupe la case) : recherche, billeterie, boutique, nurserie, nourriture, Accueil nouveaux animaux, camion, changement de milieu (couleur choisie), changement de milieu (température choisie). +- On peut déplacer dessus (occupe la case) : bébé mature, animal prêt. + +### Cases du zoo au lancement du jeu + +- 1 Agrandissement du zoo (+1 case, payant) (changement) +- 1 Recherche tout en haut à gauche +- 1 Billeterie tout en haut à gauche +- 1 Nurserie tout en haut à gauche +- 1 Accueil nouveaux animaux tout en haut à gauche (changement) +- 1 Nourriture générale tout en haut à gauche (changement) +- 1 Camion tout en haut à gauche (changement) +- 24 cases de 3 couleurs différentes + +### Attractivité des animaux + +- Les visiteurs arrivent dans le zoo depuis la billeterie et repartent par la billeterie. +- Les visiteurs restent au maximum 1 journée. +- Les visiteurs restent dans la journée plus longtemps par boutiques et en fonction du nombre d'animaux différents. +- Les visiteurs se déplacent en étant plutôt attirés. + +### Attractivité du zoo + +- Attractivité proportionnelle à la valeur cumulée des animaux du zoo. +- Attractivité proportionnelle au nombre d'animaux différents dans le zoo. +- Attractivité proportionnelle à la rareté (niveau) des animaux. +- Attractivité proportionnelle au taux de remplissage en animaux. +- Les morts pénalisent l'attractivité du zoo auprès des visiteurs à venir dans les villes. +- Les morts pénalisent l'apparition de naissances dans le zoo. +- Les naissances augmentent l'attractivité du zoo auprès des visiteurs à venir dans les villes. +- Les naissances augmentent l'apparition d'autres naissances dans le zoo. + +### Score de reproduction du zoo + +- Nombre de naissances. +- Taux d'alimentation des animaux. +- Un animal dans un zoo de bon score de reproduction aura quand il est vendu un bon score de reproduction. + +--- + +## 11. Carte du monde – cases et éléments + +### Cases du monde au lancement du jeu + +- 1 Agrandissement de la carte (grisé ou non payé en unités de recherche produites par les centres de recherche des zoos du joueur : plus cher en unité de recherche par paliers même nombre de cases ajoutées) (changement) +- 1 Compteur de bébés à vendre +- 1 Compteur d'animaux à vendre +- 1 Compteur de laboratoires +- 1 Compteur de zoos +- 1 Compteur de villes +- 1 Accueil nouveaux animaux tout en haut à gauche (changement) +- 1 Nourriture générale tout en haut à gauche (changement) +- 1 Camion tout en haut à gauche (changement) +- 24 cases de 3 couleurs différentes + +### Autres cases + +- **Case du zoo du joueur** : 1 case du nom du zoo, juste dessous 1 case du score d'attractivité, juste dessous 1 case du score de reproduction, juste dessous 1 case de vente, possibilité d'acheter sur les cases voisines d'autres cases de ventes. +- **Cases des zoos des autres joueurs** : mêmes principes que pour le joueur actif. +- **Cases des zoos des bots** : mêmes principes que pour le joueur actif. +- **Cases des villes** : 1 case nom, 1 case nombre de visiteurs maximums vers les zoos. + +--- + +## 12. Animaux + +- Niveaux de rareté des bébés de ce type d'animaux. +- Niveaux de rareté de ce type d'animaux. + +### Feedbacks visuels d'état (pas de jauges) + +- **Froid** : L'animal devient bleuâtre/pâle, givre visible sur le sprite. +- **Chaud** : L'animal devient rougeâtre, vapeur de chaleur visible. +- **Faim** : Déplacement lent, maigreur visible, cherche le sol, icône "faim" discrète. +- **Maladie/Mort proche** : L'animal est couché, couleurs ternes, mouches autour. +- **Heureux/Reproduction** : Cœurs, sautillements, couleurs vives. +- **Lisibilité** : Pour garantir la lisibilité sur mobile, les changements de couleur (teinte globale du sprite) sont privilégiés par rapport aux micro-animations (tremblements). + +### Mort + +- Seuls, pas visités, manque de nourriture, tué par un autre animal d'un autre zoo, niveau de recherche trop inférieur par rapport au niveau de l'animal, bébé non vendu dans les délais, bébé de nurserie prêt non placé dans les délais, animal d'accueil prêt non placé sur la carte après un délai, animal non placé sur la carte dans les délais (vente échouée), température trop en écart avec la température de l'animal, milieu (couleur) trop en écart avec la température de l'animal. + +### Reproduction + +- Après un délai en proximité d'un autre animal de même type mais issu d'un zoo différent (donne un nouveau bébé à placer en nurserie ou à vendre, va directement en nurserie si disponible sinon directement en vente). +- Score de reproduction du zoo qui le vend au moment de la vente accélère l'arrivée d'un bébé. +- Température en très bonne adéquation avec la température de l'animal. +- Milieu (couleur) en très bonne adéquation avec la température de l'animal. + +### Scores et paramètres + +- Score de reproduction par milieux (couleurs). +- Score de survie par milieux (couleurs). +- Température idéale. +- **Saisons** : Les 4 saisons (Printemps, Été, Automne, Hiver) influencent la météo, la température et les bonus/malus de reproduction et de survie selon le type d'animal. + +--- + +## 13. Ventes + +- Sur la carte du monde, case sous les noms des zoos (autre case) : bébé à vendre ou animal à vendre sur des cases de vente du zoo sur la carte du monde. +- Enchères entre joueurs et bots (l'utilisateur qui vend choisit de valider ou non la vente après un temps, si bébé invendu il meurt), montant initial décidé par le vendeur. +- **Validation différée et synchronisation** : + - Pour éviter les "race conditions" et ajouter une tension de jeu, la base de données attend **10 minutes** avant de valider définitivement un achat. + - Durant ce délai, un **sablier** s'affiche sur la transaction. + - L'achat n'est effectif (transfert de propriété et de fonds) qu'à la fin du sablier. +- Déplacer l'animal ou le bébé sur le camion : + - Dans la carte du zoo : met en vente, l'animal ou le bébé s'affiche avec le dernier montant des enchères sur la carte. + +--- + +## 14. Changements par rapport à la version œufs + +- Ce ne sont plus des œufs qui apparaissent dans les zoos mais des bébés animaux. +- Les zoos vendent aussi des animaux adultes. diff --git a/docs/features/attractivite-visiteurs-phase8.md b/docs/features/attractivite-visiteurs-phase8.md new file mode 100644 index 0000000..4fc634a --- /dev/null +++ b/docs/features/attractivite-visiteurs-phase8.md @@ -0,0 +1,50 @@ +# Phase 8 – Attractivité et visiteurs (billeterie, cap, score) + +**Objectif :** Plafonner les visiteurs par la capacité billeterie, calculer un score d’attractivité (valeur, espèces, rareté, remplissage, pénalités morts, bonus naissances) et l’afficher sur la carte du monde. + +**Référence :** `docs/plan-implementation-rappel-grandes-regles.md` phase 8. + +## Impacts + +- Les visiteurs sont plafonnés par la capacité billeterie (20 × niveau total billeterie). Sans billeterie, le plafond n’est pas appliqué (comportement inchangé). +- Nouveau score d’attractivité exposé dans `state.attractivityScore` et affiché sous le nom du zoo (joueur) sur la carte du monde. +- La formule d’attractivité pourra servir plus tard à l’allocation des visiteurs depuis les villes (phase 11). + +## Modifications + +- **income.js** : suppression des imports dupliqués ; `getBilleterieCapacity(state)` ; dans `getVisitorParams`, plafonnement de `visitorCount` par `getBilleterieCapacity(state)` ; `getAttractivityScore(state)` (valeur cumulée, nombre d’espèces, rareté moyenne, taux de remplissage, pénalité morts, bonus naissances). +- **config.js** : `Visitor.AttractivityDeathPenalty`, `Visitor.AttractivityBirthBonus`. +- **types.js** : `GameState.attractivityScore?`. +- **game-loop.js** : import `getAttractivityScore` ; après `tickResearch`, `state.attractivityScore = getAttractivityScore(state)`. +- **ui.js** : pour le zoo joueur, ligne « Score attractivité: X.X » sous le score de reproduction (classe `world-map-zoo-attractivity-score`). +- **main.css** : style `.world-map-zoo-attractivity-score`. + +## Disparition des animaux non visités (§2) + +- **Config** : `Visitor.MaxSecondsWithoutVisit` (300 s par défaut). Un animal dont aucune visite n’a été enregistrée sur sa case depuis plus de cette durée est retiré (case vidée, `deathCountRecent` incrémenté). +- **animal-visits.js** : `tickAnimalVisits(state, nowUnix, nowMs)` met à jour `lastVisitedAt` des cases animales sous la position actuelle des visiteurs (orbites autour du centre d’attraction). +- **food.js** : `checkDeathCauses(state, nowUnix)` utilise `MaxSecondsWithoutVisit` ; si `nowUnix - lastVisitedAt >= maxVisit`, le bloc animal est supprimé. +- **game-loop.js** : `tickAnimalVisits` est appelé avant `tickFeeding` et `checkDeathCauses`. + +## Stagnation (§3) + +- **Config** : `Visitor.StagnationDecayAfterSeconds` (60 s), `Visitor.StagnationDecayPerMinute` (0.05). Après ce délai sans action d’évolution, le multiplicateur de demande visiteurs décroît (plancher 10 %). +- **income.js** : `getStagnationMultiplier(state, nowUnix)` utilise `state.lastEvolutionAt` ; appliqué dans `getVisitorDemand(state, nowUnix)`. +- **lastEvolutionAt** est mis à jour sur : upgrade (plot, conveyor, truck, world map, school, nursery, shop, research, billeterie, food, reception, biome), place (egg, baby, reception animal), vente (sellAnimalToNpc, addMatureBabyToSale, addReceptionAnimalToSale), prestige, moveCell. + +## Implémenté ultérieurement (modèle visiteurs par entité) + +- **Durée max 1 journée par visiteur** : `state.visitorArrivals[]` avec `{ arrivedAt }` ; base = `Time.DayLengthSeconds` (1 jour) ; sortie quand `now > arrivedAt + getStayDurationSeconds(state)`. +- **Prolongation par boutiques et diversité** : `getStayMultiplier(state)` = 1 + (niveaux boutique × `Visitor.StayMultiplierPerShopLevel`) + (espèces distinctes × `Visitor.StayMultiplierPerSpecies`) ; `getStayDurationSeconds(state) = base × getStayMultiplier(state)`. +- **Config** : `Visitor.StayMultiplierPerShopLevel`, `Visitor.StayMultiplierPerSpecies` (base jour = `Time.DayLengthSeconds`). +- **Game loop** : `tickVisitorArrivals(state, nowUnix)` appelé avant `incomeTick` ; `getVisitorParams(state)` utilise `state.visitorArrivals.length` ; sans billeterie, fallback sur l’ancienne formule pour rétrocompatibilité. + +## Modalités de déploiement + +- Aucun déploiement serveur. Rechargement client suffit. + +## Modalités d’analyse + +- Sans billeterie : le nombre de visiteurs reste calculé comme avant (sans plafond). +- Avec au moins une case billeterie : le nombre de visiteurs affiché et utilisé pour les revenus ne dépasse pas (niveau total billeterie × 20). +- Carte du monde, zoo joueur : affichage « Score attractivité: X.X » mis à jour à chaque tick. diff --git a/docs/features/carte-monde-recherche-compteurs-phase9.md b/docs/features/carte-monde-recherche-compteurs-phase9.md new file mode 100644 index 0000000..c24757d --- /dev/null +++ b/docs/features/carte-monde-recherche-compteurs-phase9.md @@ -0,0 +1,28 @@ +# Phase 9 – Carte du monde : agrandissement en recherche et compteurs + +**Objectif :** Agrandissement de la carte payé en unités de recherche (plus en pièces) ; affichage des compteurs (bébés à vendre, animaux à vendre, laboratoires, zoos, villes). + +**Référence :** `docs/plan-implementation-rappel-grandes-regles.md` phase 9. + +## Impacts + +- L’agrandissement de la carte consomme `state.researchPoints` au lieu de `state.coins`. Le bouton est grisé si `researchPoints` insuffisants. +- Compteurs affichés sous la zone « Agrandir carte » : Bébés à vendre, Animaux à vendre, Laboratoires, Zoos, Villes. + +## Modifications + +- **config.js** : `WorldMap.MapUpgrade.BaseResearchCost`, `ResearchUpgradeGrowth` (coût en unités de recherche par palier). +- **economy.js** : `getWorldMapUpgradeResearchCost(currentLevel)` ; `getWorldMapUpgradeCost` conservé pour compatibilité. +- **zoo.js** : `tryUpgradeWorldMap` utilise `getWorldMapUpgradeResearchCost`, déduit `state.researchPoints` au lieu de `state.coins` ; retourne `NotEnoughResearch` si pas assez de points. +- **ui.js** : zone agrandissement carte affiche le coût en recherche (`X 🔬`) et le titre avec le coût ; `canUpgradeMap` basé sur `researchPoints >= getWorldMapUpgradeResearchCost(mapLevel)` ; nouvelle zone `world-map-counters` avec cinq compteurs mis à jour au refresh. +- **texts-fr.js** : `errorMessage.NotEnoughResearch`. +- **main.css** : `.world-map-upgrade-zone-cost`, `.world-map-counters`, `.world-map-counter`. + +## Modalités de déploiement + +- Aucun déploiement serveur. Rechargement client suffit. + +## Modalités d’analyse + +- Agrandir la carte : coût affiché en unités de recherche ; si pas assez de `researchPoints`, le bouton est grisé et un clic affiche « Pas assez d’unités de recherche ». +- Compteurs : Bébés à vendre = `saleListings` avec `isBaby` ; Animaux à vendre = `saleListings` sans `isBaby` ; Laboratoires = 1 ; Zoos = `worldZoos.length` ; Villes = `WorldMap.Cities.length`. diff --git a/docs/features/causes-mort-audit.md b/docs/features/causes-mort-audit.md new file mode 100644 index 0000000..043188f --- /dev/null +++ b/docs/features/causes-mort-audit.md @@ -0,0 +1,41 @@ +# Audit des causes de mort (§12) + +**Référence :** Cahier des charges §12 (Mort). + +## Liste du cahier + +1. Seuls +2. Pas visités +3. Manque de nourriture +4. Tué par un autre animal d'un autre zoo +5. Niveau de recherche trop inférieur par rapport au niveau de l'animal +6. Bébé non vendu dans les délais +7. Bébé de nurserie prêt non placé dans les délais +8. Animal d'accueil prêt non placé sur la carte après un délai +9. Animal non placé sur la carte dans les délais (vente échouée) +10. Température trop en écart avec la température de l'animal +11. Milieu (couleur) trop en écart avec la température de l'animal + +## Implémenté + +- **Pas visités** : `Visitor.MaxSecondsWithoutVisit`, `checkDeathCauses` → `maybeDeathBlock` (visitedOk), `food.js` + `animal-visits.js`. +- **Manque de nourriture** : `Food.MaxSecondsWithoutFood`, `maybeDeathBlock` (fedOk), `tickFeeding` / `checkDeathCauses`. +- **Température en écart** : `maybeDeathBlock` (tempOk), `getDisplayTemperature`, `idealTemperature` / `temperatureTolerance` (loot-tables). +- **Milieu (couleur) en écart** : `maybeDeathBlock` (biomeOk), `isAnimalAllowedOnBiome`, `getDisplayBiome`. +- **Bébé nurserie prêt non placé** : `Nursery.MaxSecondsMatureNotPlaced`, `filterPendingBabies` dans `checkDeathCauses`. +- **Animal accueil prêt non placé** : `Reception.MaxSecondsReadyNotPlaced`, `filterReceptionAnimals` dans `checkDeathCauses`. +- **Bébé non vendu dans les délais** : `Sale.ListingDurationSeconds`, `tickSaleListings` (trade.js) ; annonce expirée et `isBaby` → `deathCountRecent` incrémenté. Côté serveur : `expireSaleListings` (db.js). + +## Non implémenté + +- **Seuls** : pas de règle « animal seul meurt ». +- **Tué par un autre animal d'un autre zoo** : pas de mécanique inter-zoo. +- **Niveau de recherche trop inférieur** : pas de vérification niveau recherche vs niveau animal. +- **Animal (adulte) vente échouée** : à l'expiration d'une annonce adulte (`isBaby: false`), `deathCountRecent` n'est pas incrémenté (actuellement seul le bébé invendu est compté). + +## Fichiers + +- `web/js/food.js` : `checkDeathCauses`, `maybeDeathBlock`, `filterPendingBabies`, `filterReceptionAnimals`. +- `web/js/trade.js` : `tickSaleListings` (expiration bébé). +- `web/js/animal-visits.js` : `lastVisitedAt` pour cause « pas visités ». +- `server/db.js` : `expireSaleListings` (bébé invendu). diff --git a/docs/features/centralisations-mutualisations.md b/docs/features/centralisations-mutualisations.md new file mode 100644 index 0000000..37c2d30 --- /dev/null +++ b/docs/features/centralisations-mutualisations.md @@ -0,0 +1,39 @@ +# Centralisations et mutualisations + +**Objectif :** Réduire la duplication et centraliser les helpers réutilisables. + +## Modifications récentes + +### server/db.js + +- **mapZooRowBase(row)** : id, name, x, y (Number) partagés par `getAllZoos`, `getZooById`, `getBotZoosForTick`. Chaque fonction étend avec ses champs spécifiques (animal_weights/game_state, is_bot/account_id, animalWeights/botState). +- **mapSaleListingRow(row)** : mapping unique des lignes `sale_listings` pour `getSaleListingById`, `getActiveSaleListings`, `getSalesForZoo` (asSeller, asBuyerUndelivered, active). Colonnes absentes dans le SELECT deviennent `undefined`. +- **validateListingForSeller(listingId, sellerZooId)** : chargement + vérifications (ListingNotFound, ListingNotActive, NotSeller) utilisées par `acceptSale` et `rejectSale`. Retourne `{ ok: true, listing }` ou `{ ok: false, reason }`. +- **processValidatedSales** : boucle refactorée sans `continue` (blocs imbriqués) pour respecter la règle no-continue. + +### web/js/loot-tables.js + +- **zeroAnimalWeights()** : retourne un objet `{ [colorName]: 0 }` pour toutes les couleurs. Utilisé pour agrégations et valeurs initiales. + +### web/js/state.js + +- **defaultAnimalWeights()** : s’appuie sur `zeroAnimalWeights()` puis met la première couleur à 1. +- **normalizeZooWeights(legacy)** : utilise `zeroAnimalWeights()` au lieu de recréer l’objet à la main. +- **setScalarDefault(data, key, defaultVal)** : assigne `data[key]` si null/undefined ; `defaultVal` peut être une fonction (ex. `lastEvolutionAt`). Utilisé dans **applyLoadStateScalarDefaults** avec la liste **LOAD_STATE_SCALAR_DEFAULTS** (tableau [key, default]) pour éviter la répétition des ~25 lignes `if (data.x === null || ...) data.x = default`. + +### web/js/bot-zoo.js + +- **getNeighborColorWeights** : utilise `zeroAnimalWeights()` pour initialiser `out` au lieu de `Object.fromEntries(colorNames.map(...))`. + +### web/js/auto-mode-profiles.js + bot-zoo.js (déjà documenté) + +- Bots : `LEGACY_PROFILE_TO_ID` + `getProfileParams(profileId)` pour toutes les décisions (upgrade, sell, buy) ; plus de ternaires sur "fast"|"slow"|"balanced". + +## Fichiers concernés + +- server/db.js +- web/js/loot-tables.js +- web/js/state.js +- web/js/bot-zoo.js +- docs/features/ventes-encheres-phase10.md (référence mapSaleListingRow, validateListingForSeller) +- docs/features/mode-auto-50-profils.md (référence bots + profils) diff --git a/docs/features/grille-lancement.md b/docs/features/grille-lancement.md new file mode 100644 index 0000000..8b32976 --- /dev/null +++ b/docs/features/grille-lancement.md @@ -0,0 +1,45 @@ +# Grille au lancement et 3 couples reproducteurs + +**Objectif :** Au premier lancement (nouveau zoo ou après prestige), le joueur dispose d’une grille zoo complète (recherche, billeterie, nurserie, accueil, nourriture, école) et de 3 couples reproducteurs d’animaux basiques déjà placés. + +**Référence :** Cahier des charges §1 (garanties au démarrage), §10 (carte du zoo, layout). + +## Impacts + +- **Nouveau zoo / pas de sauvegarde :** `defaultState()` produit une grille 6×6 avec 6 cases fixes en ligne 1 et 6 animaux (3 couples) en ligne 2. Les 24 autres cases restent vides (3 biomes visuels par tiers de largeur, pas de champ biome sur les cases). +- **Prestige :** `doPrestige()` réinitialise la grille avec le même layout et les mêmes 3 couples. +- **Chargement ancienne sauvegarde :** `applyLoadStateLegacyCells` et `ensureSchoolCell` conservent la compatibilité (2_1 forcé en nurserie si manquant ou ancien plotUpgrade ; école ajoutée en 1_1 si absente). Les anciennes parties gardent 1_1 école et 2_1 nurserie tant qu’elles ne sont pas réinitialisées. + +## Modifications + +- **web/js/default-grid-layout.js** (nouveau, partagé) : `buildDefaultRow1Cells()`, `STARTER_ANIMAL_IDS_BY_BIOME`, `STARTER_ANIMAL_POSITIONS`, `addStarterAnimals(state)`. +- **state.js** + - `buildDefaultCells()` : appelle `buildDefaultRow1Cells()` du module partagé `default-grid-layout.js` (research, billeterie, nursery, reception, food, school en ligne 1). + - `addStarterAnimals(state)` : importée depuis `default-grid-layout.js` ; place 6 animaux (3 couples) sur la ligne 2. + - `defaultState()` : construit le state puis appelle `addDefaultStarterAnimals(state)` avant retour. +- **prestige.js** + - Même layout de grille et mêmes 3 couples après reset, via `buildDefaultRow1Cells()` et `addStarterAnimals()` importés de `default-grid-layout.js`. Réinitialisation de `pendingBabies` et `receptionAnimals`. + +## Layout détaillé + +- **Niveau 1 (6×6) :** `plotSizeFromLevel(1)` → largeur 6, hauteur 6. +- **Ligne 1 :** 1_1 research, 2_1 billeterie, 3_1 nursery, 4_1 reception, 5_1 food, 6_1 school. +- **Ligne 2 :** 1_2 et 2_2 = couple Meadow (c0_r0), 3_2 et 4_2 = couple Ocean (c5_r0), 5_2 et 6_2 = couple Mountain (c10_r0). +- **Lignes 3–6 :** vides ; 24 cases libres pour placement (affichage 3 couleurs/biomes par tiers de largeur, sans donnée biome sur cellule). + +## Modalités de déploiement + +- Client uniquement. Aucune migration BDD. Rechargement suffit. Les nouveaux zoos et les resets prestige utilisent le nouveau layout ; les sauvegardes existantes ne sont pas modifiées de force. + +## Modalités d’analyse + +- Nouveau jeu : grille avec 6 bâtiments ligne 1, 6 animaux ligne 2, 24 cases vides. +- Prestige : même grille + 3 couples ; pièces à 0, niveaux plot/conveyor/camion/carte à 1, listes pendingBabies/receptionAnimals vides. +- Chargement ancienne sauvegarde : pas de réécriture des cases 1_1/2_1 si déjà présentes (sauf correction legacy 2_1 = nursery si manquant ou ancien plotUpgrade). + +## Carte du monde au lancement (§11) + +- **Agrandissement carte** : niveau `worldMapLevel` (1 par défaut) ; zone d’upgrade en unités de recherche (config `WorldMap.MapUpgrade`). Affichage dans la vue carte du monde. +- **Compteurs** : affichés en haut de la vue carte (Bébés à vendre, Animaux à vendre, Laboratoires, Zoos, Villes). Valeurs dérivées du state et de la config (`state.saleListings`, `state.worldZoos`, `GameConfig.WorldMap.Cities`, etc.). +- **Accueil / Nourriture / Camion** : sur la carte du monde, ces fonctions sont couvertes par la même zone « camion » (achat d’œufs, vente) et les panneaux ventes (Mes ventes, À récupérer, Enchères). Pas de grille dédiée « 24 cases 3 couleurs » sur la carte du monde dans l’implémentation actuelle ; la carte affiche zoos, villes, laboratoire, compteurs et panneaux ventes. +- **Layout actuel** : zone carte (zoos, villes, labo), panneau ventes à gauche, compteurs en haut. Alignement §11 partiel (compteurs + agrandissement carte) ; les « 24 cases 3 couleurs » et les cases Accueil/Nourriture/Camion sur la carte monde restent optionnels ou à préciser en design. diff --git a/docs/features/incidents-visiteurs-phase4.md b/docs/features/incidents-visiteurs-phase4.md new file mode 100644 index 0000000..29c5bab --- /dev/null +++ b/docs/features/incidents-visiteurs-phase4.md @@ -0,0 +1,34 @@ +# Incidents visiteurs (§4.2) + +**Objectif :** Visiteurs peuvent rencontrer des incidents (soif, poubelle pleine, banc requis, animal trop loin, envie de photo). Apparition plus fréquente en phase d’attente. Bulle au-dessus du visiteur ; clic = résolution (bonus pièces + attractivité) ; non résolu = timeout puis départ du visiteur et pénalité d’attractivité. + +**Référence :** Cahier des charges §2 (Visiteurs et Incidents). + +## Impacts + +- **State :** `VisitorEntry` étendu avec `incidentType?` ("thirst"|"bin"|"bench"|"animalFar"|"photo"), `incidentSince?`. `attractivityBonusFromIncidents?` (cumul résolus − non résolus). +- **Phase d’attente :** camion en cours (`eggPurchaseTruck`, `truckSale`) ou vente en attente de validation (`asBuyerUndelivered` avec `validated_at` dans le futur). +- **Résolution :** clic sur la bulle → suppression de l’incident, ajout de pièces et bonus d’attractivité. +- **Timeout :** après `IncidentTimeoutSeconds` sans résolution, le visiteur est retiré et une pénalité d’attractivité est appliquée. + +## Modifications + +- **types.js** : `VisitorEntry` avec `incidentType?`, `incidentSince?` ; `GameState.attractivityBonusFromIncidents?`. +- **config.js** : `Visitor.IncidentChanceBase`, `IncidentChanceWaitMultiplier`, `IncidentTimeoutSeconds`, `IncidentResolveAttractivityBonus`, `IncidentResolveCoinBonus`, `IncidentUnresolvedAttractivityPenalty`. +- **visitor-incidents.js** (nouveau) : `isInWaitPhase(state)`, `tickVisitorIncidents(state, nowUnix)`, `resolveIncident(state, visitorIndex)`, `INCIDENT_TYPES`, `INCIDENT_EMOJI`. +- **game-loop.js** : appel à `tickVisitorIncidents` après `tickVisitorArrivals`. +- **income.js** : `getAttractivityScore` inclut `state.attractivityBonusFromIncidents`. +- **state.js** : au chargement, défaut `attractivityBonusFromIncidents = 0`. +- **main.js** : boucle visiteurs basée sur `state.visitorArrivals.length` ; pour chaque visiteur avec incident, affichage d’une bulle (emoji + tooltip) ; clic appelle `resolveIncident` puis `fullRender`. +- **texts-fr.js** : `incidentLabel` (soif, poubelle pleine, etc.), `incidentBubbleAria`. +- **main.css** : `.visitor-incident-bubble` (position au-dessus du sprite, cliquable). + +## Modalités de déploiement + +- Client uniquement. Rechargement suffit. + +## Modalités d’analyse + +- Avoir des visiteurs (billeterie) et attendre ou déclencher une phase d’attente (achat œuf, vente en cours) : des bulles d’incident apparaissent au-dessus de certains visiteurs. +- Clic sur une bulle : incident disparaît, pièces et attractivité augmentent. +- Ne pas cliquer : après le délai configuré, le visiteur disparaît et l’attractivité baisse. diff --git a/docs/features/mode-auto-50-profils.md b/docs/features/mode-auto-50-profils.md new file mode 100644 index 0000000..490d0f8 --- /dev/null +++ b/docs/features/mode-auto-50-profils.md @@ -0,0 +1,35 @@ +# Mode automatique – 50 profils et sélection hiérarchique + +**Objectif :** Proposer 50 profils de mode automatique répartis en 5 familles, avec une interface de sélection en deux étapes (Famille → Spécialisation) et utilisation des paramètres du profil choisi pour les décisions d’upgrade du joueur. + +**Référence :** Cahier des charges §5 ; plan d’action BLOC 3. + +## Impacts + +- **State :** `autoModeProfileId` (1–50) stocke le profil choisi ; `autoModeProfile` ("fast"|"slow"|"balanced") reste pour rétrocompatibilité et est mappé à un profil par défaut (balanced→25, fast→33, slow→7). +- **Mode auto joueur :** Les décisions d’upgrade (parcelle, compétences, camion) utilisent les paramètres du profil (seuil de dépense, probabilité d’upgrade) au lieu des 3 profils legacy uniquement. +- **UI :** Clic sur le bouton mode auto quand il est inactif ouvre un panneau (Famille → Spécialisation) ; choix d’une spécialisation active le mode auto avec ce profil. Clic quand le mode auto est actif le désactive. +- **Bots :** Les décisions upgrade/sell/buy utilisent `getProfileParams(LEGACY_PROFILE_TO_ID[profile])` ; paramètres centralisés dans auto-mode-profiles (plus de ternaires fast/slow/balanced). + +## Modifications + +- **web/js/auto-mode-profiles.js** (nouveau) : 50 profils avec id, familyId (1–5), spendThreshold, upgradeChance, sellChance, clés i18n (label, priorities, risks). Familles : Conservateurs (1–10), Éleveurs (11–20), Commerçants (21–30), Expansionnistes (31–40), Scientifiques (41–50). `getEffectiveProfileId(state)`, `getProfileParams(profileId)`, `getProfilesByFamily(familyId)`, `getAllProfiles()`. +- **web/js/types.js** : `GameState.autoModeProfileId?`, `autoProfilePickerOpen?`, `autoProfilePickerFamily?`. +- **web/js/state.js** : `saveState` omet les champs `autoProfilePickerOpen` et `autoProfilePickerFamily` avant persistance ; `applyLoadStateScalarDefaults` remet ces champs à false/undefined au chargement. +- **web/js/bot-zoo.js** : `tickPlayerAutoMode` utilise `getEffectiveProfileId(state)` et `getProfileParams(profileId)` ; `playerAutoDoOneUpgrade(state, params, rng)` reçoit des paramètres numériques. Pour les bots, `tickBotDecisions` utilise `LEGACY_PROFILE_TO_ID[b.profile]` et `getProfileParams(profileId)` ; `botDecideUpgrade`, `botDecideSell`, `botDecideBuy` reçoivent un objet `params` (spendThreshold, upgradeChance, sellChance) au lieu du libellé. +- **web/js/ui.js** : Clic sur le bouton mode auto ouvre le picker si inactif ; panneau avec 5 familles puis 10 spécialisations, bouton Annuler. Au choix d’une spécialisation : `autoModeProfileId`, `autoMode: true`, fermeture du picker. +- **web/js/texts-fr.js** : `autoProfileFamilyLabel` (5 familles), `autoProfileSpecialisationLabel` (1–50), `autoProfilePrioritiesLabel`, `autoProfileRisksLabel` (placeholders), libellés du picker. +- **web/css/main.css** : `.auto-profile-picker-wrap`, `.auto-profile-picker-title`, `.auto-profile-picker-step`, `.auto-profile-picker-families`, `.auto-profile-picker-specialisations`, boutons famille/spécialisation, bouton Annuler. + +## Modalités de déploiement + +- Client uniquement. Rechargement suffit. + +## Modalités d’analyse + +- Désactiver le mode auto, cliquer sur le bouton mode auto : le panneau « Choisir le profil » s’affiche avec 5 familles. +- Choisir une famille : affichage des 10 spécialisations. +- Choisir une spécialisation : le mode auto s’active avec le profil correspondant (🤖), le panneau se ferme. +- Annuler : le panneau se ferme sans activer le mode auto. +- Avec le mode auto actif, les upgrades automatiques utilisent les paramètres du profil sélectionné (seuil de dépense et probabilité d’upgrade). +- Ancienne sauvegarde sans `autoModeProfileId` : comportement identique à « balanced » (profil 25). diff --git a/docs/features/reproduction-phase6.md b/docs/features/reproduction-phase6.md new file mode 100644 index 0000000..ff8382e --- /dev/null +++ b/docs/features/reproduction-phase6.md @@ -0,0 +1,28 @@ +# Phase 6 – Reproduction + +**Objectif :** Deux animaux de même type, dont au moins un provient d’un autre zoo, en proximité produisent un bébé après un délai. Le délai est réduit par le score de reproduction du zoo et l’adéquation température/milieu. + +**Référence :** `docs/plan-implementation-rappel-grandes-regles.md` phase 6. + +## Impacts + +- Nouveau module `web/js/reproduction.js` (détection de paires, timers, naissances). +- État : `reproductionTimers`, `fromOtherZoo` sur animaux et bébés. +- Bébés créés par reproduction vont en nurserie si place libre, sinon en `saleListings` (vente phase 10). + +## Modifications + +- **types.js** : `AnimalCell.fromOtherZoo?`, `PendingBaby.fromOtherZoo?`, `GameState.reproductionTimers?`. +- **config.js** : `Reproduction.BaseSeconds`, `Reproduction.MaxDistance`. +- **state.js** : `defaultState.reproductionTimers`, migration `loadState` pour `reproductionTimers`. +- **zoo.js** : `addPendingBaby(state, animalId, fromOtherZoo?)`, `placeMatureBabyOnCell` / `placeReceptionAnimalOnCell` renseignent `fromOtherZoo` sur l’animal placé ; `tryBuyBaby` appelle `addPendingBaby(..., true)`. +- **reproduction.js** : `getReproductionScore`, `getBiomeReproductionFactor`, `getTemperatureFactor`, `findReproductionPairs`, `tickReproduction` ; appel depuis la game loop après `checkDeathCauses`. + +## Modalités de déploiement + +- Aucun déploiement serveur. Rechargement client suffit. + +## Modalités d’analyse + +- Vérifier en jeu : placer deux animaux de même type (dont un acheté en accueil/conveyor) sur deux cases adjacentes ; après le délai (réduit par score/biome/temp), un bébé apparaît en nurserie ou en vente. +- `state.reproductionTimers` doit contenir les paires en attente avec `dueAt` ; après échéance, entrée retirée et `birthCount` incrémenté. diff --git a/docs/features/score-reproduction-phase7.md b/docs/features/score-reproduction-phase7.md new file mode 100644 index 0000000..0d873a0 --- /dev/null +++ b/docs/features/score-reproduction-phase7.md @@ -0,0 +1,28 @@ +# Phase 7 – Score de reproduction du zoo + +**Objectif :** Exposer un score de reproduction agrégé (birthCount, feedingRate) pour affichage et pour attacher à l’entité vendue (reproductionScoreAtSale). Réutiliser `getReproductionScore` pour le délai de reproduction (phase 6) et pour l’affichage. + +**Référence :** `docs/plan-implementation-rappel-grandes-regles.md` phase 7. + +## Impacts + +- Un seul « zoo score » : `getReproductionScore(state)` utilisé dans la game loop pour le délai de reproduction et pour remplir `state.reproductionScore` (affichage). +- Carte du monde : case « Score repro » sous le nom du zoo (joueur). +- Vente : les entrées `saleListings` créées (nursery full en reproduction) portent `reproductionScoreAtSale` pour usage côté acheteur (phase 10). + +## Modifications + +- **types.js** : `GameState.reproductionScore?`. +- **game-loop.js** : après `tickReproduction`, `state.reproductionScore = getReproductionScore(state)` ; import de `getReproductionScore` depuis `reproduction.js`. +- **reproduction.js** : lors du push en `saleListings` (NoFreeNursery), ajout de `reproductionScoreAtSale: getReproductionScore(state)`. +- **ui.js** : pour le zoo joueur sur la carte du monde, ajout d’une ligne « Score repro: X.X » sous le nom (classe `world-map-zoo-reproduction-score`). +- **main.css** : style `.world-map-zoo-reproduction-score`. + +## Modalités de déploiement + +- Aucun déploiement serveur. Rechargement client suffit. + +## Modalités d’analyse + +- En jeu : onglet carte du monde, zoo joueur → sous le nom, affichage « Score repro: X.X » (mis à jour à chaque tick). +- Après une naissance (reproduction) sans place en nurserie, une entrée dans `saleListings` doit contenir `reproductionScoreAtSale` égal au score courant du zoo. diff --git a/docs/features/ventes-encheres-phase10.md b/docs/features/ventes-encheres-phase10.md new file mode 100644 index 0000000..680575b --- /dev/null +++ b/docs/features/ventes-encheres-phase10.md @@ -0,0 +1,51 @@ +# Phase 10 – Ventes et enchères (bébés et animaux adultes) + +**Objectif :** Mise en vente de bébés et d’animaux depuis le camion ; affichage des ventes sur la carte du monde ; expiration des annonces (bébé invendu meurt) ; enchères (offres joueurs/bots), validation par le vendeur, transfert vers l’acheteur (nurserie/accueil), persistance serveur (API/BDD). + +**Référence :** `docs/plan-implementation-rappel-grandes-regles.md` phase 10. + +## Vérification des phases précédentes + +Voir `docs/plan-verification-phases-0-a-9.md`. Les prérequis (4, 6, 7, 9) sont en place. + +## Impacts + +- Glisser un **bébé mature** (nurserie) ou un **animal prêt** (accueil) sur la zone camion (vente) → l’entité est retirée de la nurserie/accueil et ajoutée à `state.saleListings` ; création côté serveur via `POST /api/sales` ; l’annonce apparaît sur la carte du monde dans la case du zoo joueur. +- Les annonces expirent après un délai configurable (côté client via `tickSaleListings` et côté serveur via `expireSaleListings`) ; si l’annonce concernait un **bébé** et n’a pas été vendue, le bébé est considéré mort (`deathCountRecent` incrémenté côté client et serveur). +- **Enchères :** les joueurs et les bots peuvent enchérir sur les ventes actives (`POST /api/sales/:id/bid`). Le vendeur peut accepter ou refuser la meilleure enchère (`POST /api/sales/:id/accept`, `POST /api/sales/:id/reject`). À l’acceptation, la vente passe en statut `sold` avec un délai de **validation différée de 10 minutes** : les pièces ne sont pas transférées immédiatement ; `validated_at = now() + 10 min`. Un traitement à la lecture (`processValidatedSales`) exécute le transfert (débit acheteur, crédit vendeur) et passe le statut à `validated` lorsque `validated_at <= now()`. L’acheteur ne peut « Récupérer » qu’après validation ; un **sablier** (compte à rebours) s’affiche pour les ventes en attente. + +## Modifications + +- **config.js** : `Sale.ListingDurationSeconds` (3600), `Sale.DefaultPrice` (50). +- **trade.js** : `addMatureBabyToSale`, `addReceptionAnimalToSale`, `tickSaleListings`. +- **game-loop.js** : appel à `tickSaleListings` après `tickReproduction`. +- **ui.js** : zone camion (drop) crée la vente localement et appelle `createSale` si API configurée ; carte du monde : affichage des ventes (sync avec `getSales()` à l’ouverture) ; panneau « Mes ventes » (Accepter / Refuser), « À récupérer » (Récupérer), « Enchères » (saisie montant + Enchérir). +- **types.js** : `SaleListing` étendu avec `serverId?`, `bestBidAmount?`, `bestBidderZooId?`, `status?` ; `GameState.salesFromApi?` pour les données renvoyées par `GET /api/sales`. +- **api-client.js** : `getSales()`, `createSale(payload)`, `placeBid(listingId, amount)`, `acceptSale(listingId)`, `rejectSale(listingId)`, `deliverSale(listingId)`. +- **Serveur** + - **server/migrations/001_sale_listings.sql** : tables `sale_listings` (…), **002_sale_listings_validated_at.sql** : colonne `validated_at TIMESTAMPTZ`, statut `validated` ajouté au CHECK. + - **server/db.js** : `mapSaleListingRow(row)` centralise le mapping des lignes `sale_listings` (utilisé par `getSaleListingById`, `getActiveSaleListings`, `getSalesForZoo`). `mapZooRowBase(row)` pour zoos (getAllZoos, getZooById, getBotZoosForTick). `validateListingForSeller(listingId, sellerZooId)` pour les vérifications vendeur (acceptSale, rejectSale). … `acceptSale` (ne transfère plus les pièces ; pose `sold_at`, `validated_at = now() + 10 min`), `processValidatedSales()` (transfert et passage à `validated`), `markSaleDelivered` (exige `status = 'validated'`). + - **server/routes/sales.js** : GET `/api/sales` appelle `processValidatedSales()` avant `getSalesForZoo` si auth. Réponses incluent `validated_at`. + - **server/bot-tick.js** : après `expireSaleListings`, récupération des ventes actives et enchère aléatoire d’un bot (si assez de pièces, pas vendeur). +- **web** : panneau « À récupérer » : sablier et compte à rebours (ex. « Validation dans X min ») pour les ventes `status === 'sold'` avec `validated_at` dans le futur ; bouton « Récupérer » désactivé jusqu’à validation. **texts-fr.js** : `salesPendingValidation`, `salesValidationInMinutes`. +- **texts-fr.js** : `errorMessage.BabyNotMature`, `NoBabyInNursery`, `AnimalNotReady`, `NoAnimalInReception`. +- **web/css/main.css** : styles `.world-map-sales-panel`, `.sales-panel-title`, `.sales-panel-row`, boutons Accepter/Refuser/Récupérer/Enchérir. + +## Modalités de déploiement + +- **Base de données :** exécuter la migration `server/migrations/001_sale_listings.sql`, puis `server/migrations/002_sale_listings_validated_at.sql` (validation différée). +- Redémarrage serveur et rechargement client. + +## Modalités d’analyse + +- Glisser un bébé mature / animal prêt sur la zone camion : annonce créée localement et sur le serveur (si API configurée) ; affichage sur la carte du monde. +- Ouvrir la carte du monde avec API + auth : `getSales()` est appelé ; panneau « Mes ventes », « À récupérer », « Enchères » affiché selon les données. +- Enchérir sur une vente active : saisir un montant > meilleure enchère puis « Enchérir ». +- En tant que vendeur : « Accepter » ou « Refuser » sur la meilleure enchère ; à l’acceptation, vente en statut `sold`, `validated_at = now() + 10 min` ; pas de transfert de pièces immédiat. +- En tant qu’acheteur ayant gagné une vente : affichage sablier « Validation dans X min » tant que `validated_at > now()` ; après 10 min, un prochain GET exécute `processValidatedSales`, la vente passe en `validated`, le bouton « Récupérer » s’active ; clic ajoute le bébé/animal à la nurserie/accueil puis appelle `deliverSale`. +- Expiration : après `end_at`, la vente passe en `expired` ; si bébé, `deathCountRecent` du vendeur incrémenté (GET /api/sales ou bot tick). +- Bots : à chaque tick, un bot peut placer une enchère sur une vente active (autre zoo, assez de pièces). + +## Phase suivante + +Phase 11 – Villes : `docs/features/villes-phase11.md`. diff --git a/docs/features/villes-phase11.md b/docs/features/villes-phase11.md new file mode 100644 index 0000000..95d0483 --- /dev/null +++ b/docs/features/villes-phase11.md @@ -0,0 +1,25 @@ +# Phase 11 – Villes + +**Objectif :** Cases des villes sur la carte du monde avec nom et nombre maximum de visiteurs vers les zoos ; plafond ou répartition des visiteurs depuis les villes. + +**Référence :** `docs/plan-implementation-rappel-grandes-regles.md` phase 11. + +## Dépendances + +- Phase 8 (Attractivité et visiteurs). + +## Livrables (implémentés) + +- **Cases des villes :** Sur la carte du monde, chaque ville affiche le nom et le « nombre maximum de visiteurs vers les zoos » (`maxVisitorsTowardZoos`). Rendu : icône 🏙️ + libellé nom + ligne « max N ». +- **Règle d'attraction :** Dans `getCityAttraction` (income.js), la contribution de chaque ville est plafonnée par `city.maxVisitorsTowardZoos` : `contrib = min(maxVisitorsTowardZoos, raw * 100)` avec `raw = 1/(1+distance)`, puis somme × `CityAttractionScale`. Les villes proches contribuent plus, sans dépasser leur plafond. + +## Fichiers modifiés + +- **config.js** : `WorldMap.Cities[].maxVisitorsTowardZoos` (ex. 80, 100). +- **income.js** : `getCityAttraction()` utilise le plafond par ville. +- **ui.js** : Rendu des villes avec nom et « max N » ; tooltip et aria-label avec « max N visiteurs vers zoos ». +- **main.css** : `.world-map-city-label`, `.world-map-city-max-visitors`. + +## Phase précédente + +Phase 10 – Ventes et enchères : `docs/features/ventes-encheres-phase10.md`. diff --git a/docs/lint.md b/docs/lint.md new file mode 100644 index 0000000..a976047 --- /dev/null +++ b/docs/lint.md @@ -0,0 +1,33 @@ +# Lint – Configuration ESLint + +Configuration initialisée selon les règles du projet (Rappel des grandes règles / qualité du code). + +## Fichiers + +- **package.json** (racine) : scripts `lint`, `lint:web`, `lint:server` ; devDependencies `eslint`, `@eslint/js`, `eslint-plugin-jsdoc`. +- **eslint.config.js** : config plate (ESLint 9) pour `web/js/**/*.js` et `server/**/*.js` (hors `node_modules`). + +## Règles activées (résumé) + +- **Longueur / complexité** : max 250 lignes/fichier, 40 lignes/fonction, 4 paramètres, profondeur 4, complexité cyclomatique 10 (warn). +- **Non utilisés** : `no-unused-vars` (args/vars/erreurs commençant par `_` ignorés). +- **Égalité** : `eqeqeq` (=== / !==). +- **Variables** : `no-var`, `prefer-const`. +- **Contrôle** : `no-else-return`, `no-continue`, `no-labels`. +- **Console / debug** : `no-console` (warn, allow warn/error), `no-debugger`, `no-alert`. +- **Sécurité** : `no-eval`, `no-implied-eval`, `no-new-func`. +- **JSDoc** : `jsdoc/require-returns`, `jsdoc/check-param-names` (warn). + +Règles non disponibles en core ESLint (à appliquer manuellement ou via plugin dédié) : `prefer-nullish-coalescing`, `prefer-optional-chain` (préférer `??` et `?.` selon les règles projet). + +## Utilisation + +```bash +npm run lint # web/js + server (hors node_modules) +npm run lint:web # web/js uniquement +npm run lint:server # server uniquement +``` + +## Conformité progressive + +Le premier passage sur le dépôt signale des violations existantes (eqeqeq, no-continue, max-lines-per-function, complexity, etc.). Les corriger au fil de l’eau ou par lot ; ne pas désactiver les règles pour contourner. diff --git a/docs/plan-action-cahier-des-charges.md b/docs/plan-action-cahier-des-charges.md new file mode 100644 index 0000000..91065fe --- /dev/null +++ b/docs/plan-action-cahier-des-charges.md @@ -0,0 +1,173 @@ +# Plan d'action – Alignement du code avec le cahier des charges + +Document de référence : `docs/cahier des charges.md`. +Objectif : identifier les écarts entre le cahier des charges (version actuelle) et le code, puis ordonner les mises à jour. + +--- + +## 1. Démarrage et grille au lancement (§1, §10, §11) + +### Écarts + +- **Démarrage autonome (§1)** : ~~Le joueur doit commencer avec 3 couples reproducteurs… Non implémenté~~ **Implémenté** : `default-grid-layout.js` + `addStarterAnimals(state)` placent 6 animaux (3 couples Meadow/Ocean/Mountain) en ligne 2. +- **Grille zoo au lancement (§10)** : ~~pas de recherche, billeterie, accueil, nourriture, ni 24 cases~~ **Implémenté** : `buildDefaultRow1Cells()` (research, billeterie, nursery, reception, food, school) ligne 1 ; 24 cases vides lignes 3–6. +- **Grille monde au lancement (§11)** : 1 Agrandissement carte, compteurs (bébés, animaux, labos, zoos, villes) présents dans l’UI ; Accueil/Nourriture/Camion couverts par la zone camion et les panneaux ventes. Documenté dans `docs/features/grille-lancement.md`. + +### Actions + +1. ~~Définir le layout…~~ Fait : `default-grid-layout.js`, `buildDefaultRow1Cells()`, `addStarterAnimals()`. +2. ~~Adapter buildDefaultCells()~~ Fait : appelle `buildDefaultRow1Cells()`. +3. ~~Placer 3 couples reproducteurs~~ Fait : `addStarterAnimals(state)` dans `defaultState()` et `doPrestige()`. +4. Documenter layout carte du monde : fait dans `docs/features/grille-lancement.md` (§11). + +**Fichiers concernés :** `web/js/state.js`, `web/js/config.js`, `web/js/placement.js` (ou `grid-utils.js`), éventuellement `web/js/main-bootstrap.js`, `docs/features/` (fiche grille au lancement). + +--- + +## 2. Mode automatique et 50 profils (§5) + +### Écart + +- Le cahier exige **50 archétypes de profils** (Conservateurs 1–10, Éleveurs 11–20, Commerçants 21–30, Expansionnistes 31–40, Scientifiques 41–50), avec **sélection hiérarchique** : Famille → Spécialisation (pas une simple liste déroulante). Chaque profil affiche priorités et risques. +- Le code n’a que **3 profils** (`fast` / `slow` / `balanced`) et **aucune UI** de choix de profil (uniquement `autoModeProfile` dans le state). + +### Actions + +1. Introduire un **modèle de données des 50 profils** : identifiant, famille, spécialisation, libellé, paramètres (seuil dépense, fréquence, types d’animaux préférés, etc.), priorité et risques (texte ou clés i18n). +2. Étendre `GameState` (ou équivalent) pour stocker le **profil choisi** (ex. id du profil ou famille + spécialisation) au lieu de seulement `autoModeProfile: "fast"|"slow"|"balanced"`. +3. Adapter la **logique bot / mode auto** (`bot-zoo.js`, `game-loop.js`) pour utiliser les paramètres du profil sélectionné (seuils, fréquences, priorités). +4. Ajouter une **interface de sélection hiérarchique** : étape 1 = choix de la Famille (Conservateur, Éleveur, Commerçant, Expansionniste, Scientifique), étape 2 = choix de la Spécialisation dans la famille, avec affichage des priorités et risques. Remplacer/augmenter le simple toggle mode auto actuel. +5. Centraliser les libellés et textes dans `texts-fr.js` (noms des familles, spécialisations, priorités, risques). + +**Fichiers concernés :** `web/js/types.js`, `web/js/bot-zoo.js`, `web/js/game-loop.js`, `web/js/ui.js`, `web/js/texts-fr.js`, nouveau module optionnel `web/js/auto-mode-profiles.js` (définition des 50 profils). + +--- + +## 3. Ventes – Validation différée et sablier (§13) + +### Écart + +- Le cahier impose une **validation différée de 10 minutes** en base : l’achat n’est effectif (transfert de propriété et de fonds) qu’à la fin de ce délai ; un **sablier** doit s’afficher sur la transaction pendant ce temps. +- Le code actuel valide la vente **immédiatement** à l’acceptation (accept → sold, transfert de pièces et livraison côté acheteur sans délai). + +### Actions + +1. Étendre le **schéma BDD** des ventes : ajouter un champ type `validated_at` (ou `pending_until`) pour marquer la fin du délai de 10 minutes après acceptation ; conserver `sold_at` comme date d’acceptation par le vendeur. +2. Adapter la **logique métier serveur** : à l’accept, créer la vente en statut « vendu en attente » et enregistrer l’heure de validation future ; un cron ou un traitement à la lecture vérifie `now >= validated_at` pour effectuer le transfert de pièces et marquer la vente comme définitivement validée. L’acheteur ne peut « récupérer » qu’après cette validation. +3. **API** : exposer pour chaque vente (côté vendeur et acheteur) l’état « en attente de validation » et le temps restant (ou `validated_at`). Le client affiche le sablier à partir de ces infos. +4. **Client** : sur la carte du monde / panneau ventes, afficher un **sablier** (ou compte à rebours) pour les ventes vendues mais pas encore validées ; désactiver le bouton « Récupérer » jusqu’à validation définitive. + +**Fichiers concernés :** `server/schema.sql` ou migrations, `server/db.js`, `server/routes/sales.js`, `web/js/api-client.js`, `web/js/ui.js`, `web/js/types.js`, `docs/features/ventes-encheres-phase10.md`. + +--- + +## 4. Visiteurs, incidents et invités de luxe (§2, §10) + +### Écarts + +- **Disparition des animaux (§2)** : ~~Un animal non visité pendant une durée configurable (ex. 5 min) doit être retiré. À vérifier / implémenter.~~ **Implémenté** : `Visitor.MaxSecondsWithoutVisit` (300 s), `tickAnimalVisits` (mise à jour `lastVisitedAt`), `checkDeathCauses` (retrait si dépassement). Documenté dans `docs/features/attractivite-visiteurs-phase8.md`. +- **Incidents et exigences (§2)** : Les visiteurs peuvent rencontrer des problèmes (soif, poubelle pleine, banc requis, animal trop loin, envie de photo) ; apparition contextuelle en phase d’attente ; bulle d’icône ; résolution par clic ou action ; impact attractivité et pièces. **Implémenté** : voir `docs/features/incidents-visiteurs-phase4.md`. +- **Invités de luxe (§2)** : Une part des visiteurs (ex. 8 %) paie plus l’entrée et dépense plus en boutique. **Implémenté** : `getVisitorParams` (income.js) applique `LuxuryGuestChance`, `LuxuryEntryMultiplier` sur l’entrée et `LuxuryShopMultiplier` sur le bonus boutique ; les revenus visiteurs en tiennent compte. + +### Actions + +1. **Visitor.MaxSecondsWithoutVisit** : Ajouter la config, et dans la boucle de jeu (ou module visiteurs) retirer les animaux non visités depuis plus de X secondes ; mettre à jour `deathCountRecent` ou équivalent si le cahier le lie aux morts. +2. **Incidents** : Modéliser les types d’incidents (soif, poubelle, banc, animal loin, photo), les associer aux visiteurs, gérer l’affichage des bulles et le clic/action de résolution ; appliquer bonus/malus attractivité et pièces. Config pour fréquence en phase d’attente. +3. **Invités de luxe** : Introduire un flag ou type « luxe » sur une part des visiteurs (ex. 8 %) et modifier le calcul des revenus (entrée + boutique) pour ces visiteurs. + +**Fichiers concernés :** `web/js/config.js`, `web/js/income.js` (ou module visiteurs), `web/js/game-loop.js`, `web/js/ui.js` (bulles, clics), `web/js/types.js`, `web/js/texts-fr.js`. + +--- + +## 5. Carte du monde – Villes, stagnation, camions NPC (§3) + +### Écarts + +- **Villes (§3, §11)** : Plus le zoo est proche d’une ville, plus il attire de visiteurs (formule d’attraction). Cases villes : 1 case nom, 1 case nombre max visiteurs vers les zoos. **Implémenté** : `maxVisitorsTowardZoos` par ville dans config ; `getCityAttraction` plafonne la contribution par ville ; carte du monde affiche nom + « max N » par ville (voir `docs/features/villes-phase11.md`). +- **Stagnation (§3)** : Zoos qui n’évoluent pas subissent une baisse progressive du multiplicateur de visiteurs (jusqu’à un plancher, ex. 10 %). **Implémenté** : getStagnationMultiplier, lastEvolutionAt (voir attractivite-visiteurs-phase8). +- **Camions NPC (§3)** : Des camions (ventes d’œufs entre zoos) sont visibles sur la carte, en mouvement entre deux zoos, ajoutés périodiquement par la boucle de jeu. **Implémenté** : `world-map.js` (`addNpcTruckSale`, `shouldAddNpcTruck`, `pruneTruckSales`) ; game-loop appelle `shouldAddNpcTruck` / `addNpcTruckSale` ; ui.js affiche `worldTruckSales` dans `worldMapNpcTrucksEl` avec position interpolée selon `truckMs`. + +### Actions + +1. **Villes** : Vérifier / implémenter la formule d’attraction (proximité zoo–ville + valeur des animaux) et le plafond « max visiteurs vers zoos » par ville ; afficher sur la carte les cases ville avec nom et compteur max visiteurs (voir `docs/features/villes-phase11.md`). +2. **Stagnation** : Suivre la « dernière action d’évolution » par zoo (ou par joueur) ; appliquer un multiplicateur de visiteurs dégressif jusqu’à un plancher (ex. 10 %) si aucune action depuis un délai configurable. +3. **Camions NPC** : S’assurer que la boucle de jeu et l’UI créent et affichent bien des camions entre zoos (animations interpolées) selon la config existante. + +**Fichiers concernés :** `web/js/config.js`, `web/js/income.js` ou module attractivité, `web/js/ui.js` (carte du monde, villes), `web/js/game-loop.js`, `docs/features/villes-phase11.md`. + +--- + +## 6. Interface et barre du haut (§4) + +### Écarts + +- **Rafraîchissement (§4)** : La barre du haut ne doit pas être reconstruite à chaque mise à jour ; seules les parties dynamiques (indicateurs, grille, carte) sont rafraîchies. **Vérifié** : `setState()` appelle la closure `fullRender()` retournée par `render()` ; celle-ci ne recrée pas le DOM de la barre, elle appelle seulement `updateStatus()` puis `renderWorldMap()` et `renderGrid()`. La barre est créée une seule fois au premier `render(root, opts)`. +- **Pas d’onglets avec titres** : Pas d’onglets « Carte du zoo » / « Carte du monde » avec titres ; un seul sélecteur (icônes zoo / monde). À vérifier dans l’UI. +- **Message d’erreur** : Masqué et ne prenant pas de place lorsqu’il n’y a pas d’erreur. **Fait** : `setError("")` met `errEl.hidden = true` ; la règle CSS `.error-msg[hidden] { display: none; margin: 0; padding: 0; min-height: 0; }` garantit l’absence d’emprise au layout. + +### Actions + +1. **Barre** : Isoler la barre dans des composants ou n’actualiser que les nœuds dynamiques (indicateurs, icônes) au lieu de recréer toute la barre à chaque rendu. +2. **Onglets** : Supprimer ou ne pas afficher de titres d’onglets « Carte du zoo » / « Carte du monde » si présents ; garder uniquement le sélecteur icônes. +3. **Erreur** : S’assurer que le message d’erreur est caché (et n’occupe pas d’espace) quand `errorMsg` est vide. + +**Fichiers concernés :** `web/js/ui.js`. + +--- + +## 7. Animaux – Feedbacks visuels, morts, saisons (§12) + +### Écarts + +- **Feedbacks visuels (§12)** : Froid (bleuâtre, givre), chaud (rougeâtre, vapeur), faim (lent, maigre, icône), maladie/mort proche (couché, ternes, mouches), heureux/reproduction (cœurs, couleurs vives). Pas de jauges. Non implémenté côté rendu. +- **Mort (§12)** : Toutes les causes listées (seuls, pas visités, nourriture, tué autre zoo, recherche trop basse, bébé non vendu à temps, bébé mature non placé à temps, animal accueil non placé à temps, vente échouée, température/milieu en écart). Partiellement implémentées ; recensement dans `docs/features/causes-mort-audit.md`. +- **Saisons (§12)** : Les 4 saisons influencent météo, température, bonus/malus reproduction et survie. Non implémenté. + +### Actions + +1. **Feedbacks** : Définir les états visuels des animaux (froid, chaud, faim, maladie, heureux) à partir des données déjà calculées (température, nourriture, visite, etc.) et adapter le rendu (CSS, teintes, icônes) sans ajouter de jauges. +2. **Morts** : Auditer les causes de mort dans le code et les comparer à la liste du §12 ; implémenter les manquantes (délais bébé mature / accueil / vente échouée, température/milieu, etc.). +3. **Saisons** : Introduire une notion de saison (Printemps, Été, Automne, Hiver) liée au temps de jeu ou à la date ; faire évoluer météo, température et formules de reproduction/survie selon la saison. + +**Fichiers concernés :** `web/js/loot-tables.js`, `web/js/game-loop.js`, `web/js/income.js` (ou modules nourriture/visiteurs), `web/js/ui.js` (rendu animaux), `web/js/config.js`, `web/js/types.js`. + +--- + +## 8. Billeterie – Flux explicite (§7, §10) + +### Écart + +- Le cahier indique que la **billeterie** (entrée des visiteurs, 20 visiteurs/unité) et le flux arrivée/départ/retour selon l’attraction sont **non implémentés** (§7). Le code a déjà une capacité billeterie et un plafond de visiteurs ; le flux « entrée par la billeterie, départ avant fin de journée, retour selon attraction » reste à préciser et à coder. + +### Actions + +1. Clarifier le **design** : arrivée des visiteurs depuis les villes → billeterie (cap), durée max 1 journée, départ par la billeterie, retour selon attractivité. +2. Implémenter le flux dans le module visiteurs / income : génération des arrivées, suivi du temps passé, départ, et réinjection selon attractivité (en lien avec phase Villes si besoin). + +**Fichiers concernés :** `web/js/income.js`, `web/js/config.js`, `docs/features/` (fiche billeterie). + +--- + +## 9. Références et cohérence + +- **Règles économie (§1)** : Compatibilité anciennes sauvegardes (animal inconnu → c0_r0, œuf inconnu → Color_1) : déjà gérée dans `normalizeLoadedCells` / loadState. +- **Sécurité (§1)** : Pas de confiance au client, limitation fréquence, traçabilité : à garder en tête pour toute évolution API/serveur. +- **BDD et comptes** : Rester aligné avec `docs/bdd-comptes.md` pour tout changement de schéma ou de flux 401/404/200. + +--- + +## 10. Ordre recommandé des mises à jour + +1. **Grille au lancement + 3 couples reproducteurs** (§1, §10) – prérequis pour un démarrage conforme. +2. **Ventes – validation différée + sablier** (§13) – changement métier important et visible. +3. **Mode automatique – 50 profils et UI hiérarchique** (§5) – grosse évolution UX et données. +4. **Visitor.MaxSecondsWithoutVisit + disparition animaux** (§2) – rapide si la boucle de jeu existe déjà. +5. **Interface – barre non reconstruite, erreur masquée** (§4) – rapide. +6. **Villes – formule attraction + cases** (§3, §11) – voir `docs/features/villes-phase11.md`. +7. **Stagnation** (§3) – après ou avec le module visiteurs. +8. **Incidents visiteurs + invités de luxe** (§2) – après flux visiteurs stable. +9. **Feedbacks visuels animaux + causes de mort manquantes** (§12) – progressif. +10. **Saisons** (§12) – après température/milieu et reproduction. +11. **Billeterie – flux complet** (§7, §10) – après design validé. + +Ce plan peut être découpé en fiches `docs/features/` ou en issues par bloc, et réordonné selon les priorités du projet. diff --git a/docs/plan-implementation-rappel-grandes-regles.md b/docs/plan-implementation-rappel-grandes-regles.md new file mode 100644 index 0000000..d100b03 --- /dev/null +++ b/docs/plan-implementation-rappel-grandes-regles.md @@ -0,0 +1,321 @@ +# Plan d'implémentation – Rappel des grandes règles (cahier des charges 174-324) + +Plan pour implémenter l’intégralité du bloc « Rappel des grandes règles » sans exception. Les phases sont ordonnées par dépendances ; chaque phase livre un ensemble cohérent et testable. + +--- + +## 0. Modèle de données et configuration + +**Objectif** : Fondations pour tout le reste (cases avec couleur + température, bâtiments, bébés vs animaux, ventes). + +**Livrables** : +- **Cases** : chaque case a une **couleur** (milieu : eau douce, eau salée, montagne, prairie, forêt, etc.) et une **température** (nombre ou plage). Transitions douces = formules d’interpolation entre cases voisines (calcul côté moteur). +- **Animaux multi-cases** : définition des types d’animaux qui occupent N×M cases (shape), et stockage dans `grid.cells` (référence à une entité « animal » multi-case ou marquage des cases). +- **Types de bâtiments** (remplacement / extension des kinds actuels) : + - `research` (Centre de recherche), 7 niveaux + - `billeterie`, 7 niveaux + - `boutique` (déjà présent en `souvenirShop` → renommer/aligner), 7 niveaux + - `nursery`, 7 niveaux (au lieu de 5) + - `food` (Nourriture), 7 niveaux + - `reception` (Accueil nouveaux animaux), 7 niveaux + - `truck` (camion : actuellement métadonnée d’état, pas une case – à trancher : case dédiée ou zone comme aujourd’hui) + - `biomeChangeColor` (changement de milieu couleur), 7 niveaux + - `biomeChangeTemp` (changement de milieu température), 7 niveaux +- **Entités déplaçables** : `baby` (bébé, en nurserie ou en vente), `animal` (adulte, sur carte ou en accueil ou en vente). Plus d’œufs comme objet principal : les zoos exposent des **bébés** et des **animaux** à l’achat/vente. +- **Config** : GameConfig étendu (niveaux max à 7 pour les bâtiments listés, coûts, capacités : recherche 10 zoos/unité, billeterie 20 visiteurs/unité, boutique 5 visiteurs/unité, nurserie 1 bébé/unité, nourriture 5 animaux/unité, accueil 1 animal/unité, camion 1/unité). + +**Fichiers impactés** : `web/js/types.js`, `web/js/config.js`, `web/js/state.js`, `server/schema.sql` (si extension game_state), `web/js/loot-tables.js` (animaux avec `cellsWide`, `cellsHigh`, température idéale, score reproduction/survie par milieu). + +**Dépendances** : aucune. + +--- + +## 1. Cartes : couleurs et températures des cases + +**Objectif** : Les cases ont une couleur (milieu) et une température ; les transitions sont douces entre cases. + +**Livrables** : +- **Couleurs** : élargir les biomes au-delà de prairie/océan/montagne (eau douce, eau salée, montagne, prairie, forêt, etc.) ; chaque case a un `biome` (couleur/milieu) et un `temperature` (valeur ou min/max). +- **Transitions douces** : calcul de la couleur et de la température affichées par interpolation avec les cases voisines (ou gradient par position). Export d’une fonction du type `getDisplayColor(x, y, grid)`, `getDisplayTemperature(x, y, grid)`. +- **Rendu** : CSS/Canvas ou styles dynamiques pour fond de case selon couleur et température (dégradés entre cases). +- **Grille** : les cases forment le cadrillage des cartes (zoo et monde) ; pas de changement de structure, seulement sémantique couleur/température. + +**Fichiers impactés** : `web/js/biome-rules.js` (ou nouveau `cell-environment.js`), `web/js/grid-utils.js`, `web/css/main.css`, `web/js/ui.js` (rendu grille). + +**Dépendances** : Phase 0. + +--- + +## 2. Animaux multi-cases + +**Objectif** : Certains animaux prennent plusieurs cases. + +**Livrables** : +- **Définition** : dans les données animaux (loot-tables ou équivalent), champs `cellsWide`, `cellsHigh` (ex. 1×1, 2×2). Placement valide si toutes les cases cibles sont vides et dans les limites. +- **Stockage** : soit une entité « animal » avec `originKey` (case coin) + `animalId` + `width`, `height`, soit marquage de chaque case avec référence à la même entité. Suppression/mouvement : toute la surface est libérée ou déplacée. +- **Règles** : cohérence animal/milieu et température (phase 1) appliquée sur la zone couverte (ex. toutes les cases dans la plage de température idéale ou au moins la case d’origine). +- **UI** : affichage d’un sprite ou emprise sur plusieurs cases ; glisser-déposer d’un animal multi-case déplace tout le bloc. + +**Fichiers impactés** : `web/js/loot-tables.js`, `web/js/placement.js`, `web/js/grid-utils.js`, `web/js/ui.js`, `web/js/state.js` (structure cells). + +**Dépendances** : Phase 0, 1. + +--- + +## 3. Bâtiments zoo (types et niveaux) + +**Objectif** : Implémenter tous les types de cases « achetables » avec 7 niveaux et leurs effets. + +**Livrables** : +- **Centre de recherche** (`research`) : 7 niveaux. Produit des **unités de recherche** par tick (formule par niveau). Stock dans le game_state (ex. `researchPoints`). 1 unité = 10 zoos max couverts (par proximité sur la carte du monde) ; ces zoos débloquent des niveaux d’animaux/bébés. Coût d’upgrade par palier. +- **Billeterie** : 7 niveaux. Cap visiteurs en simultané = 20 × niveau (ou 20 par unité comme dans le rappel). Entrée des visiteurs uniquement via la billeterie (voir phase 8). Coût par palier. +- **Boutique** : passer à 7 niveaux. 1 unité = 5 visiteurs simultanés max (effet sur revenus quand un visiteur « passe » par une boutique). Coût par palier. +- **Nurserie** : 7 niveaux. 1 unité = 1 bébé max en croissance. Effet « plus rapide » et « meilleurs reproducteurs » (à lier à la reproduction, phase 7). Coût par palier. +- **Nourriture** : 7 niveaux. 1 unité = 5 animaux max nourris (voir phase 4). Coût par palier. +- **Accueil nouveaux animaux** : 7 niveaux. 1 unité = 1 animal en acclimatation. Durée d’acclimatation selon niveau ; à la fin, état « animal prêt » déplaçable sur une case ou sur le camion. Coût par palier. +- **Camion** : 7 niveaux. Représentation : soit une case dédiée « camion », soit une zone comme aujourd’hui ; 1 unité = 1 camion. Effets : plus rapide (durée trajet), dégrade moins le score de reproduction avec la durée du transport (à lier aux ventes et au score de reproduction). +- **Changement de milieu (couleur)** : 7 niveaux, payant. Permet de modifier la couleur/milieu d’une case (ou d’une zone selon niveau). Effets : plage de température plus précise, améliore reproduction, diminue besoin nourriture, allonge temps avant mort. +- **Changement de milieu (température)** : 7 niveaux, payant. Même idée pour la température des cases. + +**Grille au lancement** (à appliquer en phase 11) : 1 Agrandissement zoo, 1 Recherche, 1 Billeterie, 1 Nurserie, 1 Accueil, 1 Nourriture, 1 Camion, 24 cases (3 couleurs). Pas de « changement de milieu » au lancement. + +**Fichiers impactés** : `web/js/config.js`, `web/js/state.js`, `web/js/economy.js`, `web/js/placement.js`, `web/js/zoo.js`, `web/js/ui.js`, `server/` si game_state étendu. + +**Dépendances** : Phase 0. + +--- + +## 4. Bébés et flux nurserie / accueil (remplacement œufs) + +**Objectif** : Ce ne sont plus des œufs qui apparaissent dans les zoos mais des bébés ; flux nurserie → bébé mature, achat/accueil → animal prêt. + +**Livrables** : +- **Suppression du modèle « œuf »** comme objet acheté sur la carte du monde. Les zoos (et le labo) proposent des **bébés** ou des **animaux adultes** à l’achat. +- **Nurserie** : un bébé est « en croissance » dans une case nurserie (1 bébé par unité de capacité). À la fin de la durée : état **bébé mature**. Déplacement possible : vers une case vide du zoo (devient animal) ou vers le camion (mise en vente, voir phase 9). +- **Accueil nouveaux animaux** : un animal acheté (ou reçu) est d’abord en **accueil** (1 animal par unité). À la fin de l’acclimatation : **animal prêt**. Déplacement possible : vers une case vide du zoo ou vers le camion (mise en vente). +- **Carte du zoo** : on peut **acheter** (occupe la case) : recherche, billeterie, boutique, nurserie, nourriture, accueil, camion, changement de milieu (couleur), changement de milieu (température). On peut **déplacer dessus** (occupe la case) : bébé mature, animal prêt. +- **État** : `pendingBabies` / `receptionAnimals` avec `readyAt`, `babyId` / `animalId`, lien vers case nurserie/accueil. Quand `now >= readyAt`, l’entité est déplaçable (bébé mature / animal prêt). + +**Fichiers impactés** : `web/js/state.js`, `web/js/zoo.js`, `web/js/placement.js`, `web/js/hatching.js` (remplacer par croissance bébé + acclimatation), `web/js/conveyor.js` (offres = bébés/animaux, pas œufs), `web/js/ui.js`, `web/js/world-map.js`, API offres. + +**Dépendances** : Phase 0, 3. + +--- + +## 5. Nourriture, consommation et morts + +**Objectif** : Chaque animal a une consommation / unité de temps ; sinon il meurt. Toutes les causes de mort listées. + +**Livrables** : +- **Nourriture** : par tick, calcul de la consommation totale des animaux du zoo. Les bâtiments « nourriture » ont une capacité (5 animaux × niveau ou 5 par unité). Répartition : nourrir jusqu’à la capacité ; les animaux non nourris accumulent un déficit (ou un timer « sans nourriture »). +- **Mort si pas nourri** : au-delà d’un seuil (temps ou déficit), l’animal meurt (retiré de la grille, enregistré pour pénalités attractivité / naissances). +- **Autres causes de mort** (toutes à implémenter) : + - **Seul** : à définir (ex. animal seul sur l’île sans voisin après un délai). + - **Pas visité** : déjà en place (MaxSecondsWithoutVisit). + - **Manque de nourriture** : ci-dessus. + - **Tué par un autre animal d’un autre zoo** : règle métier (ex. événement rare ou mécanique croisée entre zoos). + - **Niveau de recherche trop inférieur** : si niveau du centre de recherche du zoo < seuil requis pour le type d’animal, après un délai l’animal meurt. + - **Bébé non vendu dans les délais** : si un bébé en vente n’est pas vendu avant une date limite, il meurt (voir phase 9). + - **Bébé de nurserie prêt non placé dans les délais** : si bébé mature n’est pas déplacé (case ou camion) avant un délai, il meurt. + - **Animal d’accueil prêt non placé sur la carte après un délai** : idem. + - **Animal non placé sur la carte dans les délais (vente échouée)** : si une vente est annulée ou expire sans que l’animal soit récupéré, mort. + - **Température trop en écart** : si la température de la case (ou de la zone) n’est pas dans la plage acceptable pour l’animal, après un délai mort. + - **Milieu (couleur) trop en écart** : idem pour le biome. +- **Historique des morts** : stockage (compteur ou liste récente) pour calcul d’attractivité et de naissances (phases 8 et 7). + +**Fichiers impactés** : `web/js/config.js`, `web/js/state.js`, nouveau `web/js/food.js` (ou dans `income.js`), `web/js/game-loop.js`, `web/js/animal-visits.js` (étendre pour morts), `web/js/loot-tables.js` (température idéale, plages par animal). + +**Dépendances** : Phase 0, 1, 3, 4. + +--- + +## 6. Reproduction + +**Objectif** : Après un délai, en proximité d’un autre animal de même type mais issu d’un zoo différent, naissance d’un bébé (nurserie ou vente). Score de reproduction du zoo et adéquation température/milieu influencent. + +**Livrables** : +- **Proximité** : deux animaux de même type (même `animalId` ou même « type ») sur des cases adjacentes (ou à distance N). Un des deux doit avoir une origine « autre zoo » (vendue achetée) pour permettre la reproduction. +- **Délai** : timer par paire ou par animal ; à l’échéance, génération d’un **bébé**. Le bébé va en nurserie si une place est libre, sinon directement en vente (case de vente sur la carte du monde). +- **Score de reproduction du zoo** (voir phase 7) : utilisé pour accélérer l’arrivée du bébé (réduction du délai). +- **Température et milieu** : « très bonne adéquation » avec la température/milieu de l’animal réduit le délai ou augmente la chance de reproduction. +- **Score de reproduction par milieux (couleurs)** et **score de survie par milieux (couleurs)** : définis dans les données animaux ; utilisés dans les formules de reproduction et de mort. +- **Température idéale** : par type d’animal (déjà prévu en phase 5 pour les morts) ; utilisée aussi pour la reproduction. + +**Fichiers impactés** : `web/js/loot-tables.js`, nouveau `web/js/reproduction.js`, `web/js/state.js`, `web/js/game-loop.js`. + +**Dépendances** : Phase 0, 1, 4, 5. + +--- + +## 7. Score de reproduction du zoo + +**Objectif** : Nombre de naissances, taux d’alimentation, et score « vendu » attaché aux animaux. + +**Livrables** : +- **Nombre de naissances** : compteur dans le game_state (incrémenté à chaque bébé né en reproduction). +- **Taux d’alimentation** : ratio (animaux nourris / animaux total) sur une fenêtre ou instantané ; stocké ou dérivé pour l’affichage et les formules. +- **Score de reproduction** (valeur agrégée du zoo) : formule combinant naissances, taux d’alimentation, et éventuellement autres facteurs. Exposé pour l’UI (carte du monde : case « score de reproduction » sous le nom du zoo). +- **Animal vendu** : quand un animal quitte le zoo (vente), il garde en mémoire le **score de reproduction du zoo au moment de la vente** (pour accélérer l’arrivée d’un bébé dans le zoo acheteur, phase 6). + +**Fichiers impactés** : `web/js/state.js`, `web/js/income.js` ou `web/js/food.js`, `web/js/reproduction.js`, `web/js/trade.js`, `web/js/world-map.js` (affichage score). + +**Dépendances** : Phase 5, 6. + +--- + +## 8. Attractivité et visiteurs (billeterie, 1 journée, boutiques) + +**Objectif** : Visiteurs entrent par la billeterie, restent max 1 journée, plus longtemps avec boutiques et nombre d’animaux différents. Attractivité du zoo avec toutes les composantes et pénalités. + +**Livrables** : +- **Billeterie** : seule entrée des visiteurs dans le zoo. Capacité simultanée = 20 × niveau billeterie (ou 20 par unité). Le nombre de visiteurs présents est plafonné par cette capacité. +- **Durée max 1 journée** : chaque visiteur a une « arrivée » ; il repart au plus tard après 1 journée (temps de jeu). Il repart par la billeterie. +- **Temps passé** : les visiteurs restent plus longtemps dans la journée s’il y a des boutiques et plus d’animaux différents (formule à définir). +- **Déplacement** : les visiteurs se déplacent en étant attirés (déjà partiellement en place ; conserver et adapter si besoin). +- **Attractivité du zoo** (formule globale) : + - proportionnelle à la valeur cumulée des animaux du zoo + - proportionnelle au nombre d’animaux différents + - proportionnelle à la rareté (niveau) des animaux + - proportionnelle au taux de remplissage en animaux + - **Pénalités** : les morts pénalisent l’attractivité auprès des visiteurs à venir (depuis les villes) ; les morts pénalisent l’apparition de naissances dans le zoo. + - **Bonus** : les naissances augmentent l’attractivité auprès des visiteurs à venir ; les naissances augmentent l’apparition d’autres naissances dans le zoo. +- **Villes** : nombre de visiteurs maximum vers les zoos (voir phase 10). L’attractivité du zoo détermine combien de ces visiteurs sont « alloués » au zoo. +- **Affichage** : sur la carte du monde, sous le nom du zoo : une case « score d’attractivité » (et une « score de reproduction », phase 7). + +**Fichiers impactés** : `web/js/income.js`, `web/js/visitor-attraction.js`, `web/js/config.js`, `web/js/state.js`, `web/js/ui.js`, `web/js/world-map.js`. + +**Dépendances** : Phase 3, 5, 7. + +--- + +## 9. Carte du monde : agrandissement en unités de recherche et compteurs + +**Objectif** : Agrandissement de la carte payé en unités de recherche ; affichage des compteurs et des cases zoo (attractivité, reproduction, vente). + +**Livrables** : +- **Agrandissement de la carte** : plus payé en pièces mais en **unités de recherche** produites par les centres de recherche des zoos du joueur. Coût en unités par palier (plus cher par palier, même nombre de cases ajoutées). Si pas assez d’unités, bouton/zone grisé. +- **Compteurs** (sur la carte du monde ou dans une barre dédiée) : + - Compteur de bébés à vendre (total ou par zoo) + - Compteur d’animaux à vendre + - Compteur de laboratoires + - Compteur de zoos + - Compteur de villes +- **Cases monde au lancement** : 1 Agrandissement carte, 1 Compteur bébés à vendre, 1 Compteur animaux à vendre, 1 Compteur laboratoires, 1 Compteur zoos, 1 Compteur villes, 1 Accueil, 1 Nourriture, 1 Camion, 24 cases 3 couleurs. +- **Case du zoo (joueur et autres)** : 1 case nom du zoo, juste en dessous 1 case score d’attractivité, juste en dessous 1 case score de reproduction, juste en dessous 1 case de vente (voir phase 10). Possibilité d’acheter sur les cases voisines d’autres cases de vente (achats multi-slots). +- Même principe pour zoos des autres joueurs et zoos bots. + +**Fichiers impactés** : `web/js/state.js`, `web/js/economy.js`, `web/js/world-map.js`, `web/js/ui.js`, `web/js/config.js`. + +**Dépendances** : Phase 3, 7, 8. + +--- + +## 10. Ventes et enchères (bébés et animaux adultes) + +**Objectif** : Bébés et animaux en vente sur la carte du monde ; enchères joueurs/bots ; vendeur valide ou non ; bébé invendu meurt. + +**Livrables** : +- **Cases de vente** : sur la carte du monde, sous le nom de chaque zoo, une (ou plusieurs) case(s) de vente affichant un bébé ou un animal à vendre, avec le dernier montant d’enchère. +- **Mise en vente** : depuis la carte du zoo, déplacer un animal ou un bébé mature sur le camion → l’entité sort du zoo et apparaît en **vente** sur la carte du monde (case de vente du joueur). +- **Enchères** : joueurs et bots peuvent enchérir. Montant initial décidé par le vendeur (ou dérivé d’un prix de base). Après un temps, le **vendeur** choisit de valider ou non la vente (acceptation de la meilleure enchère ou refus). +- **Si vente validée** : l’acheteur reçoit le bébé ou l’animal (en accueil dans son zoo, ou en nurserie si bébé). Le score de reproduction du zoo vendeur au moment de la vente est attaché à l’entité (pour accélérer bébé en phase 6). +- **Si bébé invendu** (délai dépassé sans vente validée) : le bébé **meurt** (supprimé, pénalités éventuelles). +- **Animaux adultes** : les zoos vendent aussi des animaux adultes (pas seulement des bébés) ; même flux : mise sur le camion → case de vente → enchères → validation ou refus. + +**Fichiers impactés** : `web/js/state.js`, `web/js/trade.js`, `web/js/world-map.js`, `web/js/ui.js`, `server/routes/zoos.js` ou nouveau `server/routes/trades.js` (enchères temps réel ou polling), `server/db.js` (table ou champs pour offres de vente / enchères). + +**Dépendances** : Phase 4, 6, 7, 9. + +--- + +## 11. Villes + +**Objectif** : Cases des villes avec nom et nombre de visiteurs maximum vers les zoos. + +**Livrables** : +- **Cases des villes** : sur la carte du monde, chaque ville a 1 case nom et 1 case « nombre de visiteurs maximum vers les zoos » (capacité totale ou par zoo à définir). +- **Règle** : ce nombre limite ou répartit les visiteurs qui peuvent aller vers les zoos (déjà partiellement en place avec CityAttractionScale ; adapter pour un plafond « max visiteurs vers zoos » par ville). + +**Fichiers impactés** : `web/js/config.js`, `web/js/world-map.js`, `web/js/income.js` ou `web/js/visitor-attraction.js`, `web/js/ui.js`. + +**Dépendances** : Phase 8. + +--- + +## 12. UI et grille au lancement + +**Objectif** : Grille zoo et monde conformes au rappel ; transitions douces visibles ; tous les types de cases et actions. + +**Livrables** : +- **Carte du zoo au lancement** : 1 Agrandissement du zoo (+1 case, payant), 1 Recherche (en haut à gauche), 1 Billeterie, 1 Nurserie, 1 Accueil nouveaux animaux, 1 Nourriture, 1 Camion, 24 cases de 3 couleurs différentes. Positions exactes (ex. 1_1 = Recherche, 2_1 = Billeterie, …) à fixer dans `defaultState()` et config. +- **Carte du monde au lancement** : 1 Agrandissement de la carte (payé en unités de recherche), compteurs (bébés, animaux, labos, zoos, villes), 1 Accueil, 1 Nourriture, 1 Camion, 24 cases de 3 couleurs. Layout à définir (même zone que la grille zoo ou zone dédiée). +- **Transitions douces** : rendu des couleurs et températures avec interpolation (phase 1) visible sur les deux cartes. +- **Actions** : achat sur case vide (recherche, billeterie, boutique, nurserie, nourriture, accueil, camion, changement de milieu couleur, changement de milieu température) ; déplacement sur case vide (bébé mature, animal prêt). Messages d’erreur et feedback clairs. +- **Accessibilité** : ARIA, clavier, contraste (règles projet). + +**Fichiers impactés** : `web/js/state.js`, `web/js/ui.js`, `web/js/world-map.js`, `web/css/main.css`. + +**Dépendances** : Toutes les phases précédentes. + +--- + +## 13. Migration et compatibilité + +**Objectif** : Anciennes sauvegardes (modèle œufs/école actuel) restent jouables ou migration propre. + +**Livrables** : +- **Détection de version** : `game_state.version` ou `game_state.specVersion` pour distinguer « ancien » (œufs, école, 5 niveaux) et « nouveau » (bébés, recherche, 7 niveaux, etc.). +- **Migration** : script ou logique au chargement : si ancienne version, soit conversion (œufs → bébés en nurserie, école → centre de recherche niveau 1, etc.), soit message « sauvegarde incompatible, recommencer ». +- **API et BDD** : extension de `game_state` (JSONB) pour tous les nouveaux champs ; pas de perte de données existantes si migration choisie. + +**Fichiers impactés** : `web/js/state.js` (loadState, defaultState), `server/routes/zoos.js`, éventuellement script de migration côté serveur. + +**Dépendances** : Toutes les phases 0–12. + +--- + +## Synthèse des dépendances + +``` +0 (modèle) ─┬─ 1 (couleurs/temp) ─┬─ 2 (multi-cases) + │ └─ 5 (nourriture/morts) + ├─ 3 (bâtiments) + └─ 4 (bébés/flux) + │ + ├─ 5 (nourriture/morts) ─ 7 (score repro) + ├─ 6 (reproduction) ──── 7 + ├─ 8 (attractivité/visiteurs) + ├─ 9 (carte monde recherche/compteurs) + └─ 10 (ventes/enchères) ─ 11 (villes) + │ + └─ 12 (UI / grilles lancement) ─ 13 (migration) +``` + +**Ordre recommandé d’implémentation** : 0 → 1 → 3 → 4 → 2 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13. + +--- + +## Checklist exhaustive (référence 174-324) + +- [ ] Cases : couleur (milieu) + température ; transitions douces +- [ ] Animaux multi-cases +- [ ] Centre de recherche : 7 niv., unités de recherche, 10 zoos/unité, déblocage niveaux animaux/bébés +- [ ] Billeterie : 7 niv., 20 visiteurs/unité, entrée/sortie visiteurs +- [ ] Boutique : 7 niv., 5 visiteurs simultanés/unité +- [ ] Nurserie : 7 niv., 1 bébé/unité, bébé mature → case ou camion +- [ ] Nourriture : 7 niv., consommation/animal, mort si pas nourri, 5 animaux/unité, reproduction +- [ ] Accueil nouveaux animaux : 7 niv., 1 animal/unité, animal prêt → case ou camion +- [ ] Camion : 7 niv., bébé/animal sur camion → vente carte monde, 1 camion/unité +- [ ] Changement de milieu (couleur) : 7 niv., payant +- [ ] Changement de milieu (température) : 7 niv., payant +- [ ] Bébés animaux (plus d’œufs) ; zoos vendent bébés et animaux adultes +- [ ] Morts : seuls, pas visités, nourriture, tué autre zoo, recherche trop basse, bébé non vendu à temps, bébé mature non placé à temps, animal accueil non placé à temps, vente échouée, température/milieu en écart +- [ ] Reproduction : délai, proximité même type autre zoo, score repro, température/milieu adéquats ; score repro/survie par milieu ; température idéale +- [ ] Score de reproduction du zoo : naissances, taux alimentation, score vendu sur l’animal +- [ ] Attractivité : valeur cumulée, nombre d’animaux différents, rareté, taux remplissage ; pénalités morts ; bonus naissances +- [ ] Visiteurs : entrée billeterie, max 1 journée, plus longtemps avec boutiques et diversité animaux, déplacement attiré +- [ ] Carte du monde : agrandissement en unités de recherche ; compteurs bébés, animaux, labos, zoos, villes ; cases zoo (nom, attractivité, reproduction, vente) ; villes (nom, max visiteurs vers zoos) +- [ ] Ventes : cases de vente sous les zoos ; enchères joueurs/bots ; vendeur valide ou non ; bébé invendu meurt +- [ ] Grille zoo au lancement : 1 agrandissement, 1 recherche, 1 billeterie, 1 nurserie, 1 accueil, 1 nourriture, 1 camion, 24 cases 3 couleurs +- [ ] Grille monde au lancement : 1 agrandissement carte (recherche), compteurs, 1 accueil, 1 nourriture, 1 camion, 24 cases 3 couleurs +- [ ] Migration anciennes sauvegardes / compatibilité diff --git a/docs/plan-verification-phases-0-a-9.md b/docs/plan-verification-phases-0-a-9.md new file mode 100644 index 0000000..75b68b7 --- /dev/null +++ b/docs/plan-verification-phases-0-a-9.md @@ -0,0 +1,131 @@ +# Vérification des phases 0 à 9 avant phase 10 + +**Référence :** `docs/plan-implementation-rappel-grandes-regles.md`. +**Objectif :** S’assurer que tout le nécessaire pour la phase 10 (Ventes et enchères) est en place. + +--- + +## Phase 0 – Modèle de données et configuration + +| Livrable | Statut | Note | +|----------|--------|------| +| Cases couleur + température | Fait | biome-rules, grid, config | +| Animaux multi-cases (shape, stockage) | Fait | cellsWide/cellsHigh dans types, placement, loot-tables | +| Types de bâtiments 7 niveaux | Fait | research, billeterie, souvenirShop, nursery, food, reception, truck, biomeChangeColor, biomeChangeTemp dans config | +| Entités déplaçables (bébé, animal) | Partiel | pendingBabies, receptionAnimals, saleListings en place ; conveyor et labo utilisent encore des œufs (eggType) | +| Config (niveaux max, coûts, capacités) | Fait | GameConfig étendu | + +**Blocant phase 10 :** Non. saleListings, pendingBabies, receptionAnimals sont en place. + +--- + +## Phase 1 – Cartes : couleurs et températures + +| Livrable | Statut | +|----------|--------| +| Biomes / température par case | Fait (biome-rules.js, getDisplayBiome, getDisplayTemperature) | +| Transitions douces (interpolation) | Fait | +| Rendu grille | Fait | + +--- + +## Phase 2 – Animaux multi-cases + +| Livrable | Statut | +|----------|--------| +| cellsWide / cellsHigh (loot-tables, placement) | Fait | +| Stockage et mouvement du bloc | Fait (placement, zoo, hatching) | + +--- + +## Phase 3 – Bâtiments zoo (types et niveaux) + +| Livrable | Statut | +|----------|--------| +| Research, Billeterie, Boutique, Nurserie, Food, Reception, Truck, BiomeColor, BiomeTemp | Fait (config 7 niv., build/upgrade dans zoo, placement, ui) | + +--- + +## Phase 4 – Bébés et flux nurserie / accueil + +| Livrable | Statut | Note | +|----------|--------|------| +| Bébés (nurserie → mature) | Fait | pendingBabies, readyAt, placeMatureBabyOnCell | +| Accueil (reception → animal prêt) | Fait | receptionAnimals, placeReceptionAnimalOnCell | +| Achat / déplacement (bébé mature, animal prêt) | Fait | zoo, placement, ui | +| Suppression complète du modèle « œuf » | Partiel | Conveyor et labo exposent encore des œufs ; bébés/animaux en parallèle | + +**Blocant phase 10 :** Non. Flux bébé/animal et saleListings (nursery full) existent. + +--- + +## Phase 5 – Nourriture, consommation et morts + +| Livrable | Statut | Note | +|----------|--------|------| +| Nourriture (capacité, répartition, lastFedAt) | Fait | food.js, getFoodCapacity, tickFeeding | +| Mort si pas nourri | Fait | checkDeathCauses, MaxSecondsWithoutFood | +| Pas visité | Fait | MaxSecondsWithoutVisit | +| Température / milieu en écart | Fait | maybeDeathBlock (temp + biome) | +| Bébé mature non placé à temps | Fait | filterPendingBabies, MaxSecondsMatureNotPlaced | +| Animal accueil prêt non placé à temps | Fait | filterReceptionAnimals, MaxSecondsReadyNotPlaced | +| Autres causes (seul, tué autre zoo, recherche trop basse, bébé non vendu, vente échouée) | Non fait | Optionnel pour phase 10 ; « bébé invendu meurt » = phase 10 | + +**Blocant phase 10 :** Non. + +--- + +## Phase 6 – Reproduction + +| Livrable | Statut | +|----------|--------| +| Paires même type + autre zoo, adjacentes | Fait (findReproductionPairs, blocksAreAdjacent) | +| Délai (score repro, biome, température) | Fait (tickReproduction, timers, dueAt) | +| Bébé → nurserie ou vente (saleListings) | Fait (addPendingBaby, NoFreeNursery → saleListings) | + +--- + +## Phase 7 – Score de reproduction du zoo + +| Livrable | Statut | +|----------|--------| +| getReproductionScore (birthCount, feedingRate) | Fait | +| Affichage carte monde (score repro) | Fait (world-map-zoo-reproduction-score) | +| reproductionScoreAtSale sur vente | Fait (reproduction.js lors du push saleListings) | + +--- + +## Phase 8 – Attractivité et visiteurs + +| Livrable | Statut | +|----------|--------| +| Billeterie = seule entrée, cap 20/unité | Fait (getBilleterieCapacity, tickVisitorArrivals) | +| Durée max 1 journée par visiteur | Fait (visitorArrivals, arrivedAt, getStayDurationSeconds) | +| Prolongation boutiques / diversité | Fait (getStayMultiplier, StayMultiplierPerShopLevel, StayMultiplierPerSpecies) | +| Score attractivité (valeur, espèces, rareté, remplissage, pénalités, bonus) | Fait (getAttractivityScore) | +| Affichage carte monde (score attractivité) | Fait (world-map-zoo-attractivity-score) | + +--- + +## Phase 9 – Carte du monde : recherche et compteurs + +| Livrable | Statut | +|----------|--------| +| Agrandissement carte en unités de recherche | Fait (getWorldMapUpgradeResearchCost, tryUpgradeWorldMap) | +| Compteurs (bébés à vendre, animaux à vendre, labos, zoos, villes) | Fait (worldMapCounters dans ui.js) | +| Case zoo : nom, score attractivité, score reproduction | Fait (world-map-zoo-name, -reproduction-score, -attractivity-score) | +| Case de vente sous le zoo | Partiel | Un slot « world-map-zoo-slot » existe ; il affiche conveyor offers (œufs / bébé/animal) ; phase 10 doit y afficher saleListings + enchères | + +**Blocant phase 10 :** Non. Le slot existe ; phase 10 ajoute l’affichage des ventes (saleListings) et les enchères. + +--- + +## Synthèse pour la phase 10 + +- **Prérequis phase 10 (4, 6, 7, 9) :** Tous satisfaits au niveau nécessaire. +- **Manques non bloquants :** + - Phase 0/4 : modèle œuf encore présent (conveyor, labo) ; phase 10 peut s’appuyer sur saleListings et bébés/animaux tels qu’ils sont. + - Phase 5 : certaines causes de mort (seul, autre zoo, recherche trop basse, bébé non vendu) non implémentées ; « bébé invendu meurt » sera ajouté en phase 10. + - Phase 9 : case de vente = slot actuel ; phase 10 y branchera les saleListings et l’UI d’enchères. + +**Conclusion :** On peut démarrer la phase 10 (Ventes et enchères). Les écarts listés ci-dessus pourront être traités en parallèle ou dans les phases suivantes. diff --git a/docs/specs/accueil_animaux.md b/docs/specs/accueil_animaux.md new file mode 100644 index 0000000..bb8b91e --- /dev/null +++ b/docs/specs/accueil_animaux.md @@ -0,0 +1,189 @@ +# Spécifications : Accueil des Animaux + +## Définition +Zone tampon pour les nouveaux animaux arrivant dans le zoo (achats adultes ou transferts). + +## Fonctionnalités +- **Acclimatation** : Période obligatoire avant de pouvoir placer l'animal sur la grille active. +- **Stockage temporaire** : Évite de devoir placer immédiatement un animal acheté si le terrain n'est pas prêt. + +## Niveaux d'Amélioration (7 niveaux) +- **Coût** : Progressif par palier. +- **Effet** : Réduit le temps d'acclimatation. Améliore le potentiel reproducteur de l'animal entrant. +- **Ratio** : 1 unité couvre 1 animal en cours d'accueil. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "animal_reception", + "level": "integer (1-7)", + "slots": [ + { "animal_id": "uuid", "arrival_time": "timestamp", "ready_time": "timestamp" } + ] +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale (Niv 1) | +| :--- | :--- | +| Temps Acclimatation | 10 minutes | +| Capacité | 1 animal | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Présent au démarrage. + +### Conditions de Disparition +Indestructible. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Zone neutre (T° idéale simulée). + +### Impact Milieu (Biome) +Zone neutre. + +### Impact Saisons +Protégé. + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +**Bonus Repro** : Un animal passant par un accueil de haut niveau gagne un bonus temporaire de fertilité (+10% à +50%). + +### Impact Mort +Pas de mort possible en zone d'accueil (Stase). + +### Impact Nourriture +Nourri automatiquement. + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Réduction Temps Acclimatation** : +| Niveau | Temps Base | +| :--- | :--- | +| 1 | 10 min | +| 2 | 8 min | +| 3 | 6 min | +| 4 | 4 min | +| 5 | 2 min | +| 6 | 1 min | +| 7 | Instantané | + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Arrivée** : Animal livré par camion. +* **Prêt** : Animal prêt à être placé. + +## 7. Progression +### Tableau des Upgrades +| Niveau | Coût (Pièces) | Capacité | Temps Acclimatation | Temps Construction | +| :--- | :--- | :--- | :--- | :--- | +| 1 | 100 | 1 | 10 min | 0s | +| 2 | 200 | 2 | 8 min | 0s | +| 3 | 400 | 3 | 6 min | 0s | +| 4 | 800 | 4 | 4 min | 0s | +| 5 | 1600 | 5 | 2 min | 0s | +| 6 | 3200 | 6 | 1 min | 0s | +| 7 | 6400 | 7 | 0 min | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function updateReception(center): + for slot in center.slots: + if currentTime >= slot.ready_time: + notifyPlayer("ANIMAL_READY", slot.animal_id) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `ANIMAL_READY` | Info | "Un nouvel animal est prêt à rejoindre le zoo !" | +| `RECEPTION_FULL` | Warning | "Accueil saturé, impossible d'acheter." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Arrivée Animal (Passif) +**Description UX** : Le camion décharge un animal. Il apparaît dans l'Accueil en mode "Attente". +**Description UI** : Notification "Livraison effectuée". L'icône de l'Accueil change (ex: porte fermée -> porte avec caisse devant). +**Notification Push** : "Votre [Animal] est arrivé à l'accueil !" envoyée si le joueur est hors ligne. +**Emplacement** : Case Accueil. +**Intégration** : Suite de l'action Camion. +**Navigation** : N/A +**Événements** : `DELIVERY_COMPLETE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `truck_door.mp3`, `crate_drop.mp3`. +- **Graphiques** : Sprite Caisse en bois. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Camion qui repart. +- **Couleurs** : Marron (Caisse). +- **Textes** : N/A +- **Formes** : N/A + +### Consultation & Acclimatation (Consultation) +**Description UX** : Le joueur vérifie le temps restant avant de pouvoir placer l'animal. +**Description UI** : **Bottom Sheet** listant les animaux en transit. Barre de progression "Acclimatation" pour chacun. +**Emplacement** : Modal Accueil. +**Intégration** : Liste verticale. +**Navigation** : Clic Accueil -> Modal. +**Événements** : `OPEN_RECEPTION`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `ui_open.mp3` +- **Graphiques** : Style "Quarantaine" ou "Étable propre". +- **Images** : Portrait animal. +- **Vidéos** : N/A +- **Animations** : Barre de progression qui avance. +- **Couleurs** : Orange (En cours), Vert (Prêt). +- **Textes** : "Prêt dans X min". +- **Formes** : Barres arrondies. + +### Transfert vers Zoo (Action) +**Description UX** : L'animal est prêt. Le joueur le glisse vers le zoo. +**Description UI** : Bouton "Placer" ou Drag & Drop depuis la liste. +**Alternative** : Tap to Select -> Tap to Place. +**Emplacement** : Modal Accueil -> Carte Zoo. +**Intégration** : Mode placement (comme Nurserie). +**Navigation** : Drag -> Drop. +**Événements** : `PLACE_ANIMAL`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `animal_happy.mp3` (cri de l'animal). +- **Graphiques** : Sprite Animal. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Animal qui saute de joie au placement. +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/achat_upgrade_boutique.md b/docs/specs/achat_upgrade_boutique.md new file mode 100644 index 0000000..e14da2e --- /dev/null +++ b/docs/specs/achat_upgrade_boutique.md @@ -0,0 +1,40 @@ +# Spécifications : Achat et Upgrade des Boutiques + +## Construction +- Peut être construite sur n'importe quelle case vide. + +## Upgrade (7 Niveaux) +- **Attrait** : Attire les visiteurs de plus loin sur la grille. +- **Revenu** : Augmente le montant dépensé par chaque visiteur qui passe sur la case. +- **Capacité** : Augmente le nombre de visiteurs pouvant interagir simultanément. + +# Annexes Techniques + +## 1. Données et États +Voir `boutique.md` pour le modèle de données complet. + +## 7. Progression +### Tableau des Upgrades +Voir `boutique.md` pour le tableau détaillé des coûts et effets par niveau. + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Rénovation (Action) +**Description UX** : Améliorer la boutique change son apparence et ses stats. +**Description UI** : Le sprite du bâtiment change (devient plus grand/beau). +**Emplacement** : Case Boutique. +**Intégration** : Visuel immédiat. +**Navigation** : Upgrade -> Validation. +**Événements** : `UPGRADE_SHOP_VISUAL`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `construction_finish.mp3`. +- **Graphiques** : Sprites Boutiques Niv 1-7. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Effet de "pop" ou échafaudage rapide. +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/achat_upgrade_camion.md b/docs/specs/achat_upgrade_camion.md new file mode 100644 index 0000000..03c6137 --- /dev/null +++ b/docs/specs/achat_upgrade_camion.md @@ -0,0 +1,62 @@ +# Spécifications : Achat et Upgrade des Camions + +## Achat +- Le joueur possède un camion par défaut. +- Possibilité d'acheter des camions supplémentaires (si le design autorise plusieurs flottes). + +## Upgrade (7 Niveaux) +- **Niveau 1** : Camion standard. +- **Niveau 2-7** : + - **Vitesse** : Augmente la vitesse de déplacement sur la carte du monde. + - **Confort** : Réduit la perte de santé/stress des animaux transportés. + - **Capacité** : (Optionnel) Transport de plusieurs animaux ? *Actuellement 1 unité = 1 camion.* + +# Annexes Techniques + +## 1. Données et États +Voir `camion.md` pour le modèle de données complet. + +## 7. Progression +### Tableau des Upgrades +Voir `camion.md` pour le tableau détaillé des coûts et effets par niveau. + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Garage / Atelier (Consultation) +**Description UX** : Le joueur gère sa flotte de camions. +**Description UI** : Liste des camions avec stats (Vitesse, Confort). Bouton "Améliorer". +**Emplacement** : Bâtiment Garage (si existe) ou Menu Transport. +**Intégration** : Modal. +**Navigation** : Menu -> Transport. +**Événements** : `OPEN_GARAGE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `wrench.mp3` (bruit d'outil). +- **Graphiques** : Fond garage. +- **Images** : Sprites Camions évolutifs (Vieux tacot -> Camion futuriste). +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Gris, Métal. +- **Textes** : "Niveau X". +- **Formes** : N/A + +### Customisation (Cosmétique) +**Description UX** : Changer la couleur du camion. +**Description UI** : Palette de couleurs. +**Emplacement** : Garage. +**Intégration** : Sélecteur. +**Navigation** : Clic Couleur -> Appliquer. +**Événements** : `PAINT_TRUCK`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `spray.mp3`. +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Camion change de couleur. +- **Couleurs** : Palette 16 couleurs. +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/achat_upgrade_case.md b/docs/specs/achat_upgrade_case.md new file mode 100644 index 0000000..e2b1880 --- /dev/null +++ b/docs/specs/achat_upgrade_case.md @@ -0,0 +1,56 @@ +# Spécifications : Achat et Upgrade des Cases + +## Construction +- Sur une case vide de type "Terrain", le joueur peut acheter un bâtiment. +- Le coût dépend du type de bâtiment. + +## Types Constructibles +- Nurserie +- Boutique +- (Les autres bâtiments comme Billeterie/Recherche sont souvent uniques et upgradables, mais on peut imaginer en construire d'autres si le design le permet). + +## Upgrade de Case (Terrain) +- **Changement de Milieu** : Payer pour changer la couleur (Biome) d'une case. +- **Changement de Température** : Payer pour installer un régulateur thermique sur une case. + +# Annexes Techniques + +## 1. Données et États +Voir `case_zoo.md` et `achat_upgrade_generique.md`. + +## 7. Progression +### Tableau des Upgrades +**Changement de Milieu** : +| Niveau | Coût | Précision | Temps | +| :--- | :--- | :--- | :--- | +| 1 | 500 | Changement vers biome adjacent | 0s | +| 7 | 5000 | Changement vers n'importe quel biome | 0s | + +**Régulateur Thermique** : +| Niveau | Coût | Plage Réglage | Temps | +| :--- | :--- | :--- | :--- | +| 1 | 200 | +/- 5°C | 0s | +| 7 | 2000 | +/- 50°C | 0s | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Modification Terrain (Action) +**Description UX** : Le joueur sélectionne une case et choisit "Modifier Biome" ou "Température". +**Description UI** : Palette de couleurs (Biomes) ou Slider (Température). +**Mode Peinture** : Permet de glisser le doigt pour appliquer le biome/température à plusieurs cases adjacentes rapidement. +**Emplacement** : Menu Contextuel Case. +**Intégration** : Outil pinceau ou slider. +**Navigation** : Clic Case -> Outil -> Appliquer. +**Événements** : `TERRAFORM_CASE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `digging.mp3` (Biome), `hissing_gas.mp3` (Température). +- **Graphiques** : Icône Pelle, Thermomètre. +- **Images** : Textures sol (Herbe, Sable, Neige). +- **Vidéos** : N/A +- **Animations** : Transition de couleur (Fade) sur la case. +- **Couleurs** : Vert, Jaune, Blanc. +- **Textes** : "Coût : X". +- **Formes** : N/A diff --git a/docs/specs/achat_upgrade_case_zoo.md b/docs/specs/achat_upgrade_case_zoo.md new file mode 100644 index 0000000..b83910c --- /dev/null +++ b/docs/specs/achat_upgrade_case_zoo.md @@ -0,0 +1,44 @@ +# Spécifications : Achat et Upgrade des Cases du Zoo (Agrandissement) + +## Agrandissement du Zoo +- **Action** : Acheter une nouvelle parcelle de terrain adjacente à la grille existante. +- **Coût** : Très élevé, progressif selon la taille actuelle du zoo. +- **Contenu** : La nouvelle case arrive avec un biome par défaut (selon sa position géographique Prairie/Océan/Montagne) et vide. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Action sur l'objet `Zoo` (dimensions). + +### Caractéristiques Initiales +N/A + +## 7. Progression +### Tableau des Upgrades (Agrandissement) +| Extension | Coût (Pièces) | Gain | +| :--- | :--- | :--- | +| +1 Colonne | `1000 * (LargeurActuelle^2)` | +Hauteur cases | +| +1 Ligne | `1000 * (HauteurActuelle^2)` | +Largeur cases | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Achat Parcelle (Action) +**Description UX** : Le joueur clique sur une zone "Hors Limites" adjacente pour l'acheter. +**Description UI** : Zone grisée ou avec panneau "À Vendre". Au survol, prix affiché. +**Emplacement** : Bords de la Carte Zoo. +**Intégration** : Grille étendue. +**Navigation** : Clic Zone Grisée -> Confirmer Achat. +**Événements** : `EXPAND_ZOO`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `land_buy.mp3`. +- **Graphiques** : Panneau "For Sale". +- **Images** : Texture herbe (cachée par brouillard). +- **Vidéos** : N/A +- **Animations** : Brouillard se dissipe, herbe apparaît. +- **Couleurs** : Gris (Inaccessible) -> Vert (Accessible). +- **Textes** : "Acheter Terrain (X Pièces)". +- **Formes** : Carré. diff --git a/docs/specs/achat_upgrade_centre_recherche.md b/docs/specs/achat_upgrade_centre_recherche.md new file mode 100644 index 0000000..835cfcf --- /dev/null +++ b/docs/specs/achat_upgrade_centre_recherche.md @@ -0,0 +1,36 @@ +# Spécifications : Achat et Upgrade des Centres de Recherche + +## Upgrade (7 Niveaux) +- **Production** : Augmente le nombre d'Unités de Recherche générées par heure. +- **Portée** : Augmente le rayon de visibilité sur les offres des autres zoos (voir les détails des animaux lointains). + +# Annexes Techniques + +## 1. Données et États +Voir `centre_recherche.md` pour le modèle de données complet. + +## 7. Progression +### Tableau des Upgrades +Voir `centre_recherche.md` pour le tableau détaillé des coûts et effets par niveau. + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Arbre Technologique (Consultation) +**Description UX** : Visualiser les paliers de recherche. +**Description UI** : Arbre vertical ou liste de niveaux avec cadenas (verrouillé/déverrouillé). +**Emplacement** : Modal Recherche. +**Intégration** : Onglet "Améliorations". +**Navigation** : Clic Recherche -> Onglet Upgrade. +**Événements** : `VIEW_TECH_TREE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Lignes de connexion. +- **Images** : Icônes Radar, Fiole. +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Bleu (Science). +- **Textes** : "Niveau Suivant : Portée +10km". +- **Formes** : N/A diff --git a/docs/specs/achat_upgrade_generique.md b/docs/specs/achat_upgrade_generique.md new file mode 100644 index 0000000..757df65 --- /dev/null +++ b/docs/specs/achat_upgrade_generique.md @@ -0,0 +1,127 @@ +# Spécifications : Achat et Upgrade (Générique) + +## Mécanique +- **Coût** : Payé en Pièces (ou Unités de Recherche pour la Carte du Monde). +- **Progression** : 7 Niveaux pour la plupart des éléments. +- **Formule de Coût** : `Coût_Niveau_N = Coût_Base * Multiplicateur^(N-1)`. + +## Validation +- L'achat est instantané si les fonds sont disponibles. +- L'effet est immédiat (pas de temps de construction, sauf si spécifié autrement). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +N/A + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Interface d'Achat (Consultation) +**Description UX** : Le joueur ouvre le menu de construction ou d'upgrade. +**Description UI** : Grille d'éléments achetables. Prix affiché clairement. Grisé si trop cher. +**Emplacement** : HUD Bas. +**Intégration** : Menu déroulant ou fixe. +**Navigation** : Clic Catégorie -> Liste Objets. +**Événements** : `OPEN_SHOP_MENU`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `ui_hover.mp3`. +- **Graphiques** : Icônes Bâtiments. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Fond sombre, Texte clair. +- **Textes** : Prix, Nom. +- **Formes** : Cartes carrées. + +### Confirmation Achat (Action) +**Description UX** : Le joueur clique pour acheter. +**Description UI** : Feedback immédiat (son + animation pièces déduites). +**Emplacement** : HUD. +**Intégration** : Immédiat. +**Navigation** : Clic Achat. +**Événements** : `BUY_ITEM`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `buy_success.mp3`. +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Compteur pièces descend. +- **Couleurs** : Rouge (déduction). +- **Textes** : "-500". +- **Formes** : N/A diff --git a/docs/specs/achat_upgrade_nurserie.md b/docs/specs/achat_upgrade_nurserie.md new file mode 100644 index 0000000..5b15573 --- /dev/null +++ b/docs/specs/achat_upgrade_nurserie.md @@ -0,0 +1,40 @@ +# Spécifications : Achat et Upgrade des Nurseries + +## Construction +- Nécessaire pour faire éclore plus d'œufs simultanément. + +## Upgrade (7 Niveaux) +- **Incubation** : Réduit le temps d'éclosion. +- **Soins** : Augmente les chances de survie des bébés (réduit l'impact des écarts de température). +- **Génétique** : (Niveaux élevés) Augmente légèrement la probabilité d'obtenir une rareté supérieure. + +# Annexes Techniques + +## 1. Données et États +Voir `nurserie.md` pour le modèle de données complet. + +## 7. Progression +### Tableau des Upgrades +Voir `nurserie.md` pour le tableau détaillé des coûts et effets par niveau. + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Extension (Action) +**Description UX** : Ajouter des slots d'œufs. +**Description UI** : La grille de slots s'agrandit (nouvelles cases déverrouillées). +**Emplacement** : Modal Nurserie. +**Intégration** : UI Grid. +**Navigation** : N/A +**Événements** : `UNLOCK_SLOT`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `unlock.mp3`. +- **Graphiques** : Cadenas qui s'ouvre. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Slot qui s'illumine. +- **Couleurs** : N/A +- **Textes** : "Slot débloqué". +- **Formes** : N/A diff --git a/docs/specs/agrandissement_carte.md b/docs/specs/agrandissement_carte.md new file mode 100644 index 0000000..8d46337 --- /dev/null +++ b/docs/specs/agrandissement_carte.md @@ -0,0 +1,55 @@ +# Spécifications : Agrandissement de la Carte du Monde + +## Concept +La carte du monde est vaste, mais le joueur ne voit initialement qu'une petite zone autour de son zoo. + +## Mécanique +- **Coût** : Ne se paie pas en pièces, mais en **Unités de Recherche** produites par les Centres de Recherche. +- **Palier** : Chaque niveau d'agrandissement coûte plus cher. +- **Effet** : Dézoome la carte / Débloque le brouillard de guerre, révélant de nouveaux zoos, villes et laboratoires plus lointains. +- **Intérêt** : Accès à plus de marchés (achat/vente), plus de villes (visiteurs potentiels). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Propriété `view_radius` sur `Zoo` ou `Player`. + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Rayon Vue | 100 km | + +## 7. Progression +### Tableau des Upgrades +| Niveau | Coût (Points Recherche) | Rayon Vue | +| :--- | :--- | :--- | +| 1 | 0 | 100 km | +| 2 | 100 | 200 km | +| 3 | 250 | 350 km | +| 4 | 500 | 550 km | +| 5 | 1000 | 800 km | +| 6 | 2500 | 1200 km | +| 7 | 5000 | 2000 km | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Découverte (Passif/Action) +**Description UX** : En payant des points de recherche, le brouillard de guerre recule sur la carte du monde. +**Description UI** : Animation de nuages qui s'écartent. De nouvelles icônes (Villes, Zoos) apparaissent. +**Emplacement** : Carte Monde. +**Intégration** : Vue globale. +**Navigation** : Bouton "Explorer" (HUD) -> Animation. +**Événements** : `EXPLORE_WORLD`. + +#### Assets +- **Musiques** : Jingle découverte (style Zelda/Civ). +- **Sons** : `wind_whoosh.mp3`. +- **Graphiques** : Nuages (Brouillard de guerre). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Fade out des nuages. Scale up de la vue (Dezoom). +- **Couleurs** : Blanc/Gris (Nuages). +- **Textes** : "Nouvelle zone découverte !". +- **Formes** : Cercle (Rayon de vue). diff --git a/docs/specs/animal_generique.md b/docs/specs/animal_generique.md new file mode 100644 index 0000000..cfd3de4 --- /dev/null +++ b/docs/specs/animal_generique.md @@ -0,0 +1,262 @@ +# Spécifications : Animal (Générique) + +## Définition +Entité biologique principale du jeu. + +## Propriétés +- **Type/Espèce** : Défini par le loot (75 animaux possibles). +- **Génétique** : + - **Couleur** : 15 variations. + - **Rareté** : 5 niveaux (pondération Fibonacci). +- **État** : Faim, Température, Santé, Âge. +- **Cycle de vie** : Œuf -> Bébé -> Adulte -> Mort. + +## Besoins +- **Nourriture** : Consommation périodique. +- **Environnement** : Adéquation Biome/Température. +- **Social** : Besoin de congénères (reproduction) ou de solitude (selon espèce). + +## Mort +Causes possibles : Faim, Froid/Chaud, Solitude, Maladie, Vieillesse (si implémenté), Attaque (si implémenté). +Conséquence : Perte sèche, pénalité d'attractivité. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid_v4", + "type": "string (ex: 'rabbit')", + "rarity": "integer (1-5)", + "color_id": "integer (0-14)", + "name": "string", + "birth_date": "timestamp", + "state": { + "health": "float (0-100)", + "hunger": "float (0-100)", + "temperature_comfort": "float (-1.0 to 1.0)", + "stress": "float (0-100)" + }, + "genetics": { + "parent_a_id": "uuid_v4 | null", + "parent_b_id": "uuid_v4 | null", + "mutation_factor": "float" + }, + "position": { "x": "int", "y": "int" } +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Santé | 100.0 | +| Faim | 0.0 (Rassasié) | +| Stress | 0.0 | +| Âge | 0 (Bébé) | + +### Scores Initiaux +| Score | Valeur | +| :--- | :--- | +| Valeur Vente (Base) | 10 * Fibonacci(Rareté) | +| Attractivité (Base) | 5 * Rareté | + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +| Type | Condition | +| :--- | :--- | +| Œuf | Achat Carte Monde / Loot Labo | +| Bébé | Éclosion Œuf / Naissance (Reproduction) | +| Adulte | Fin croissance Bébé / Achat Carte Monde | + +### Conditions de Disparition +| Cause | Seuil | +| :--- | :--- | +| Mort (Faim) | Faim >= 100 pendant 24h (in-game) | +| Mort (Santé) | Santé <= 0 | +| Vente | Transaction validée | +| Libération | Action joueur | + +### Hérédité +| Paramètre | Formule / Logique | +| :--- | :--- | +| Rareté | `Moyenne(Parents) + Random(-1, +1)` (Pondéré par mutation) | +| Couleur | `Parent_A` (50%) ou `Parent_B` (50%) + Faible chance mutation | + +## 3. Impacts Environnementaux +### Impact Température +| Delta T° (Idéal - Réel) | Effet sur Santé / Heure | Effet sur Stress / Heure | +| :--- | :--- | :--- | +| +/- 0 (Idéal) | 0 | -5 | +| +/- 1 (Supportable) | -1 | +2 | +| +/- 2 (Inconfort) | -5 | +10 | +| +/- 3 (Danger) | -20 | +25 | +| > +/- 3 (Mortel) | -50 | +50 | + +### Impact Milieu (Biome) +| Compatibilité | Effet sur Santé / Heure | Effet sur Stress / Heure | +| :--- | :--- | :--- | +| Parfaite (Même couleur) | +1 (Régénération) | -5 | +| Adjacente (Cercle chromatique) | 0 | 0 | +| Opposée | -5 | +10 | + +### Impact Saisons +| Saison | Modificateur Faim | Modificateur Santé | +| :--- | :--- | :--- | +| Printemps | Normal | Bonus Régénération | +| Été | +10% (Soif/Faim) | Malus si T° > Seuil | +| Automne | Normal | Normal | +| Hiver | +20% (Besoin énergie) | Malus si T° < Seuil | + +### Impact Heure / Jour-Nuit +| Cycle | Modificateur Faim | Modificateur Stress | +| :--- | :--- | :--- | +| Jour | Normal | Normal | +| Nuit | -50% (Sommeil) | Récupération rapide (si calme) | + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +| Condition | Bonus/Malus Score Repro | +| :--- | :--- | +| Santé > 80% | +10 | +| Faim < 20% | +10 | +| Stress < 10% | +20 | +| Partenaire compatible < 2 cases | Déclencheur | + +### Impact Mort +| Événement | Effet | +| :--- | :--- | +| Mort d'un congénère (Rayon 5) | Stress +20 (Immédiat) | +| Mort isolée | N/A | + +### Impact Nourriture +| État Faim | Effet Santé / Heure | +| :--- | :--- | +| 0-20% | +2 | +| 20-50% | 0 | +| 50-80% | -2 | +| 80-100% | -10 | + +### Impact Attractivité (Visiteurs/Animaux) +| Rareté | Attractivité (Rayon d'action) | +| :--- | :--- | +| 1 (Commun) | 2 cases | +| 2 (Peu commun) | 3 cases | +| 3 (Rare) | 5 cases | +| 4 (Épique) | 8 cases | +| 5 (Légendaire) | 12 cases (Tout le zoo) | + +### Impact Valeur +| Facteur | Multiplicateur Valeur | +| :--- | :--- | +| Santé 100% | x1.2 | +| Santé < 50% | x0.5 | +| Âge (Adulte jeune) | x1.5 | +| Âge (Vieux) | x0.8 | + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A (Concerne les bâtiments) + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Maladie** : Si Santé < 20% et hygiène faible. +* **Naissance** : Si conditions reproduction réunies. +* **Mort** : Si Santé <= 0. + +## 7. Progression +### Tableau des Upgrades +N/A (L'animal n'a pas d'upgrade, il a de l'expérience/âge) + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function updateAnimal(animal, context): + deltaTemp = abs(animal.idealTemp - context.tileTemp) + if deltaTemp > 0: + animal.health -= tableImpactTemp[deltaTemp] + animal.stress += tableImpactTemp[deltaTemp] * 0.5 + + if context.tileBiome != animal.idealBiome: + animal.stress += 5 + + if animal.hunger > 0: + animal.hunger += baseHungerRate * context.seasonMod + + if animal.hunger >= 100: + animal.health -= 10 +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `ANIMAL_HUNGRY` | Warning | "Un animal a faim !" | +| `ANIMAL_SICK` | Alerte | "Un animal est malade." | +| `ANIMAL_DEAD` | Critique | "Un animal est mort." | +| `ANIMAL_BORN` | Info | "Un bébé est né !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Vie Quotidienne (Passif) +**Description UX** : L'animal vit sa vie sur la grille. Il bouge, mange, dort. +**Description UI** : Sprite animé. Si problème (faim/froid), feedback visuel immédiat (changement couleur/tremblement). +**Emplacement** : Case Zoo. +**Intégration** : Grille principale. +**Navigation** : N/A +**Événements** : `ANIMAL_IDLE`, `ANIMAL_MOVE`. + +#### Assets +- **Musiques** : Ambiance nature (oiseaux, vent). +- **Sons** : Cris d'animaux aléatoires (faible volume). +- **Graphiques** : Sprites Pixel Art (4 directions). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Idle, Walk, Eat, Sleep. +- **Couleurs** : Selon espèce + Teinte bleue (Froid) / Rouge (Chaud). +- **Textes** : Bulle "Zzz" (Sommeil), "!" (Alerte). +- **Formes** : N/A + +### Consultation (Consultation) +**Description UX** : Le joueur clique sur l'animal pour voir ses détails (Santé, Faim, Génétique). +**Description UI** : **Bottom Sheet** (Mobile) ou Panneau Latéral (Desktop) pour ne pas masquer le jeu. Jauges colorées. Arbre généalogique simplifié. +**Emplacement** : Case Zoo. +**Intégration** : Focus caméra sur l'animal. +**Navigation** : Clic Animal -> Panneau. +**Événements** : `SELECT_ANIMAL`. + +#### Assets +- **Musiques** : N/A +- **Sons** : Cri spécifique de l'animal sélectionné. +- **Graphiques** : Portrait HD de l'animal. +- **Images** : Icônes Santé (Cœur), Faim (Cuisse), Température (Thermomètre). +- **Vidéos** : N/A +- **Animations** : Jauges qui se remplissent. +- **Couleurs** : Vert (>70%), Orange (30-70%), Rouge (<30%). +- **Textes** : Nom, Espèce, Âge. +- **Formes** : Panneau arrondi. + +### Interaction (Action) +**Description UX** : Le joueur peut nourrir, soigner ou déplacer l'animal. +**Description UI** : Boutons d'action dans le panneau de consultation. +**Emplacement** : Panneau Consultation. +**Intégration** : Actions contextuelles. +**Navigation** : Clic Action -> Résultat immédiat. +**Événements** : `FEED_ANIMAL`, `HEAL_ANIMAL`, `MOVE_ANIMAL`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `eat.mp3`, `heal.mp3`. +- **Graphiques** : Particules (Cœurs, Étoiles). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Animal saute de joie. +- **Couleurs** : N/A +- **Textes** : "+10 PV", "-20 Faim". +- **Formes** : N/A diff --git a/docs/specs/attractivite_interne_zoo.md b/docs/specs/attractivite_interne_zoo.md new file mode 100644 index 0000000..b086932 --- /dev/null +++ b/docs/specs/attractivite_interne_zoo.md @@ -0,0 +1,126 @@ +# Spécifications : Attractivité dans le Zoo sur les Visiteurs + +## Définition +Comportement des visiteurs une fois qu'ils sont entrés dans l'enceinte du zoo. + +## Mécanique de Déplacement +Les visiteurs ne se déplacent pas au hasard. Ils ont des "cibles" d'intérêt. +- **Cibles Prioritaires** : Animaux Rares > Animaux Communs > Boutiques. +- **Algorithme** : À chaque intersection ou fin d'action, le visiteur évalue l'attrait des cases environnantes. + +## Facteurs de Rétention +- **Diversité** : Voir des animaux différents prolonge la visite. +- **Confort** : La présence de bancs, poubelles et boutiques augmente le temps de séjour. +- **Départ** : Si l'attrait local tombe sous un seuil (ennui) ou si la journée finit, le visiteur se dirige vers la sortie (Billeterie). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Propriétés de `Visiteur`. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +**Canicule/Grand Froid** : Réduit le temps de séjour (-30%). + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +Voir `visiteur.md` (Départ immédiat). + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +**Poids des Cibles** : +| Type Cible | Poids | +| :--- | :--- | +| Animal Rareté 1 | 10 | +| Animal Rareté 5 | 100 | +| Boutique | 50 | +| Banc (si fatigué) | 200 | + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +Algorithme de choix de cible pondéré. + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function chooseNextTarget(visitor, zoo): + candidates = getAllPOIs(zoo) + scoredCandidates = candidates.map(c => { + score = c.attractiveness / distance(visitor, c) + return { target: c, score: score } + }) + return weightedRandomSelect(scoredCandidates) +``` + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Foule (Passif) +**Description UX** : Les visiteurs s'agglutinent devant les animaux les plus intéressants. +**Description UI** : Densité de sprites visiteurs plus élevée autour des cases "Stars". +**Emplacement** : Grille Zoo. +**Intégration** : Comportement IA. +**Navigation** : N/A +**Événements** : `CROWD_GATHER`. + +#### Assets +- **Musiques** : N/A +- **Sons** : Rumeur de foule (volume variable selon densité). +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Applaudissements, Photos. +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/attractivite_zoo_sur_ville.md b/docs/specs/attractivite_zoo_sur_ville.md new file mode 100644 index 0000000..3df4299 --- /dev/null +++ b/docs/specs/attractivite_zoo_sur_ville.md @@ -0,0 +1,129 @@ +# Spécifications : Attractivité des Zoos sur les Visiteurs des Villes + +## Définition +Capacité d'un zoo à faire déplacer des visiteurs depuis une ville située sur la Carte du Monde. + +## Formule d'Attraction +L'attraction est calculée pour chaque couple (Ville, Zoo). +`Attraction = (Score_Reputation_Zoo / Distance^2) * Facteur_Ville` + +## Facteurs d'Influence +1. **Score de Réputation** : Plus le zoo est prestigieux, plus il attire de loin. +2. **Distance** : La proximité géographique sur la carte du monde est déterminante. +3. **Concurrence** : Les visiteurs se répartissent entre les zoos attractifs. + +## Impact +- Détermine le flux entrant quotidien de visiteurs (le nombre de personnes qui passent la porte de la Billeterie). +- Si l'attraction est nulle, aucun visiteur ne vient. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Calculé à la volée ou mis en cache. +```json +{ + "zoo_id": "uuid", + "city_id": "uuid", + "attraction_score": "float" +} +``` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +**Hiver** : Réduit la volonté de voyager (-20% rayon d'attraction). + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +Voir formule. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function calculateDailyVisitors(zoo, city): + dist = distance(zoo.pos, city.pos) + if dist > zoo.maxAttractionRange: return 0 + + baseFlow = city.population * (zoo.reputation / (dist * dist)) + return baseFlow * seasonMod +``` + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Visualisation Flux (Consultation) +**Description UX** : Le joueur regarde la carte du monde pour voir d'où viennent ses visiteurs. +**Description UI** : Lignes pointillées mouvantes reliant les villes au zoo. Épaisseur = Volume flux. **Particules** : Petits points ou voitures circulant sur les lignes pour donner une sensation de vie. +**Emplacement** : Carte Monde. +**Intégration** : Overlay. +**Navigation** : N/A +**Événements** : `VIEW_FLOW`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Lignes de flux. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Pointillés qui avancent (effet fourmis). +- **Couleurs** : Blanc ou Vert fluo. +- **Textes** : "150 visiteurs/jour". +- **Formes** : Courbes de Bézier. diff --git a/docs/specs/bebe_animal.md b/docs/specs/bebe_animal.md new file mode 100644 index 0000000..2f29b7b --- /dev/null +++ b/docs/specs/bebe_animal.md @@ -0,0 +1,196 @@ +# Spécifications : Bébé Animal + +## Définition +Stade de développement entre l'éclosion de l'œuf et l'âge adulte. + +## Spécificités +- **Lieu** : Doit grandir en Nurserie (recommandé) ou sur une case adaptée. +- **Fragilité** : Plus sensible aux écarts de température/faim que les adultes. +- **Vente** : Peut être vendu comme "Bébé" (souvent plus cher car potentiel reproducteur intact). +- **Évolution** : Devient "Bébé mature" en fin de nurserie, prêt à être placé sur la grille comme adulte. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Structure identique à `Animal` (voir `animal_generique.md`), avec un flag ou état spécifique : +```json +{ + "is_baby": true, + "growth_progress": "float (0-100)", + "nurserie_id": "uuid_v4 | null" +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Santé | 100.0 | +| Faim | 0.0 | +| Croissance | 0.0 | + +### Scores Initiaux +| Score | Valeur | +| :--- | :--- | +| Valeur Vente | 1.5 * Valeur Adulte (Potentiel) | +| Attractivité | 2 * Attractivité Adulte (Effet "Mignon") | + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +| Type | Condition | +| :--- | :--- | +| Naissance | Reproduction réussie | +| Éclosion | Fin timer œuf en Nurserie | + +### Conditions de Disparition +| Cause | Seuil | +| :--- | :--- | +| Croissance terminée | Croissance >= 100 (Devient Adulte) | +| Mort | Santé <= 0 (Plus fragile) | + +### Hérédité +Voir `animal_generique.md`. + +## 3. Impacts Environnementaux +### Impact Température +**Facteur de fragilité : x2** par rapport à l'adulte. +| Delta T° | Effet sur Santé / Heure | +| :--- | :--- | +| +/- 1 | -2 | +| +/- 2 | -10 | +| > +/- 2 | -40 (Mort rapide) | + +### Impact Milieu (Biome) +| Compatibilité | Effet | +| :--- | :--- | +| Mauvais Biome | Stress x2 par rapport à l'adulte | + +### Impact Saisons +Voir `animal_generique.md`. + +### Impact Heure / Jour-Nuit +Voir `animal_generique.md`. + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A (Un bébé ne se reproduit pas). + +### Impact Mort +| Événement | Effet Satisfaction | +| :--- | :--- | +| Mort d'un bébé | Malus Réputation x2 (Tristesse visiteurs) | + +### Impact Nourriture +**Métabolisme rapide :** Faim augmente 1.5x plus vite que l'adulte. + +### Impact Attractivité (Visiteurs/Animaux) +| Rareté | Attractivité | +| :--- | :--- | +| Toutes | Bonus "Cuteness" (+50% rayon attraction) | + +### Impact Valeur +| Facteur | Effet | +| :--- | :--- | +| Croissance 0-50% | Valeur max (Potentiel) | +| Croissance 50-99% | Valeur décroissante vers prix adulte | + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Croissance** : Passage à l'âge adulte (Notification). +* **Besoin Soin** : Alerte plus fréquente. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function updateBaby(baby, context): + # Fragilité accrue + damageMultiplier = 2.0 + growthRate = baseGrowthRate * nurserieLevelBonus + + baby.growth_progress += growthRate + + if baby.growth_progress >= 100: + transformToAdult(baby) + + # Reste de la logique héritée de Animal avec damageMultiplier +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `BABY_GROWN` | Info | "Un bébé est devenu adulte !" | +| `BABY_CRITICAL` | Critique | "Un bébé est en danger critique !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Croissance en Nurserie (Passif) +**Description UX** : Le bébé est visible dans son slot de nurserie. Il grandit visuellement (petite jauge). +**Description UI** : Sprite "Bébé" (tête disproportionnée, grands yeux). Jauge de croissance. +**Emplacement** : Modal Nurserie. +**Intégration** : Slot. +**Navigation** : N/A +**Événements** : `BABY_GROWING`. + +#### Assets +- **Musiques** : N/A +- **Sons** : Petits bruits aigus. +- **Graphiques** : Sprite Bébé. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Respiration (Scale up/down léger). +- **Couleurs** : N/A +- **Textes** : "Bébé [Espèce]". +- **Formes** : N/A + +### Sortie Prématurée (Action) +**Description UX** : Le joueur décide de placer le bébé dans le zoo avant l'âge adulte (risqué). +**Description UI** : Warning "Attention, ce bébé est fragile !". +**Emplacement** : Nurserie -> Zoo. +**Intégration** : Confirmation modale. +**Navigation** : Drag -> Confirm -> Drop. +**Événements** : `PLACE_BABY`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `warning_beep.mp3`. +- **Graphiques** : Icône Danger. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Rouge. +- **Textes** : "Risque de mort élevé !". +- **Formes** : N/A + +### Passage Adulte (Événement) +**Description UX** : Le bébé atteint 100% de croissance. Il se transforme en adulte. +**Description UI** : Flash lumineux. Le sprite change. Notification "Adulte !". +**Emplacement** : Nurserie ou Zoo. +**Intégration** : Immédiat. +**Navigation** : N/A +**Événements** : `BECOME_ADULT`. + +#### Assets +- **Musiques** : Jingle évolution. +- **Sons** : `level_up.mp3`. +- **Graphiques** : Particules brillantes. +- **Images** : Sprite Adulte. +- **Vidéos** : N/A +- **Animations** : Morphing ou Fade cross. +- **Couleurs** : Blanc/Or. +- **Textes** : "Adulte". +- **Formes** : N/A diff --git a/docs/specs/billeterie.md b/docs/specs/billeterie.md new file mode 100644 index 0000000..709007a --- /dev/null +++ b/docs/specs/billeterie.md @@ -0,0 +1,191 @@ +# Spécifications : Billeterie + +## Définition +Bâtiment d'entrée du zoo. Point d'apparition et de départ des visiteurs. + +## Fonctionnalités +- **Entrée/Sortie** : Génère le flux de visiteurs. +- **Vente de tickets** : Génère des revenus par visiteur entrant. +- **Capacité** : Limite le nombre de visiteurs simultanés dans le zoo. + +## Niveaux d'Amélioration (7 niveaux) +- **Coût** : Progressif par palier. +- **Effet** : Augmente le prix du ticket et la capacité maximale de visiteurs simultanés. +- **Ratio** : 1 unité couvre 20 visiteurs simultanés max. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "ticket_booth", + "level": "integer (1-7)", + "position": { "x": "int", "y": "int" }, + "stats": { + "total_entries": "integer", + "daily_revenue": "integer" + } +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale (Niv 1) | +| :--- | :--- | +| Prix Ticket | 10 pièces | +| Capacité Max | 20 visiteurs | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Présent au démarrage (Unique). + +### Conditions de Disparition +Indestructible. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +| Saison | Modificateur Prix Ticket | +| :--- | :--- | +| Été | +20% (Haute saison) | +| Hiver | -10% (Basse saison) | + +### Impact Heure / Jour-Nuit +**Ouverture** : 08h00 - 20h00. Fermé la nuit (sauf événements). + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +Point d'entrée unique. L'attractivité globale du zoo détermine le flux qui se présente à la billeterie. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Flux Entrée** : 1 visiteur / seconde max. + +### Dépenses (Boutiques/Visiteurs) +Revenu principal (Ticket). + +### Trajet Visiteurs +Point de départ (Spawn) et d'arrivée (Despawn). + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Entrée** : Gain pièces. +* **Refoulement** : Si Capacité Max atteinte. + +## 7. Progression +### Tableau des Upgrades +| Niveau | Coût (Pièces) | Prix Ticket | Capacité Max | Temps Construction | +| :--- | :--- | :--- | :--- | :--- | +| 1 | 0 (Base) | 10 | 20 | 0s | +| 2 | 200 | 12 | 40 | 0s | +| 3 | 400 | 15 | 70 | 0s | +| 4 | 800 | 18 | 110 | 0s | +| 5 | 1600 | 22 | 160 | 0s | +| 6 | 3200 | 28 | 220 | 0s | +| 7 | 6400 | 35 | 300 | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function processEntry(zoo): + potentialVisitors = calculateAttractionFlow(zoo) + + if zoo.currentVisitors >= zoo.billeterie.capacity: + emitEvent("ZOO_FULL") + return + + actualVisitors = min(potentialVisitors, zoo.billeterie.capacity - zoo.currentVisitors) + + for i in 0..actualVisitors: + spawnVisitor() + player.addCoins(zoo.billeterie.ticketPrice) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `ZOO_FULL` | Alerte | "Le zoo est complet ! Agrandissez la billeterie." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Consultation +**Description UX** : Le joueur clique sur la billeterie pour voir ses statistiques et son niveau. +**Description UI** : **Bottom Sheet** ou Panneau Latéral affichant le niveau actuel, la capacité, le prix du ticket et les revenus du jour. +**Emplacement** : Case Billeterie sur la grille du zoo. +**Intégration** : Bâtiment fixe (ne peut pas être déplacé ou supprimé). +**Navigation** : Clic -> Ouverture Panneau -> Fermeture (Croix ou clic extérieur). +**Événements** : `CLICK_BILLETERIE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `ui_open.mp3` +- **Graphiques** : Pixel Art, style guichet. +- **Images** : `billeterie_lvl[1-7].png` +- **Vidéos** : N/A +- **Animations** : Légère surbrillance au survol. +- **Couleurs** : Palette UI standard. +- **Textes** : "Billeterie", "Niveau X", "Revenus : Y". +- **Formes** : Carré (case). + +### Amélioration (Upgrade) +**Description UX** : Le joueur décide d'améliorer la billeterie pour augmenter la capacité et le prix du ticket. +**Description UI** : Bouton "Améliorer" dans le panneau de consultation, avec coût et prévisualisation des gains. +**Emplacement** : Panneau de consultation. +**Intégration** : Action immédiate si fonds suffisants. +**Navigation** : Clic "Améliorer" -> Confirmation (optionnelle) -> Mise à jour UI. +**Événements** : `UPGRADE_BILLETERIE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `upgrade_success.mp3` (caisse enregistreuse). +- **Graphiques** : Icône flèche vers le haut. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Particules de succès (confettis/étoiles) autour du bâtiment. +- **Couleurs** : Bouton Vert (si achetable), Gris (si fonds insuffisants). +- **Textes** : "Améliorer (X pièces)". +- **Formes** : Bouton rectangulaire arrondi. + +### Flux Visiteurs (Passif) +**Description UX** : Le joueur observe les visiteurs entrer dans le zoo. +**Description UI** : Petits sprites de visiteurs apparaissant à la porte et se dispersant. +**Emplacement** : Devant la billeterie. +**Intégration** : Animation continue pendant les heures d'ouverture. +**Navigation** : N/A +**Événements** : `VISITOR_SPAWN`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `visitor_entry.mp3` (bruit de foule léger, pas à chaque spawn). +- **Graphiques** : Sprites visiteurs variés. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Marche (4 directions). +- **Couleurs** : Variées (vêtements). +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/bot.md b/docs/specs/bot.md new file mode 100644 index 0000000..e509c69 --- /dev/null +++ b/docs/specs/bot.md @@ -0,0 +1,162 @@ +# Spécifications : Bot (Profil d'Absence) + +## Définition +Intelligence Artificielle qui gère un zoo. Peut être un concurrent permanent ou le remplaçant du joueur en cas d'absence. + +## Profils d'Absence (50 Archétypes) +Le joueur choisit un profil pour gérer son zoo quand il est hors ligne. +Sélection hiérarchique : **Famille** > **Spécialisation**. + +### Familles +1. **Les Conservateurs** : Priorité survie, achat nourriture, pas d'expansion. +2. **Les Éleveurs** : Priorité reproduction, optimisation placements. +3. **Les Commerçants** : Priorité achat/vente, spéculation. +4. **Les Expansionnistes** : Priorité agrandissement terrain/bâtiments. +5. **Les Scientifiques** : Priorité recherche et école. + +## Fonctionnement +- **Cycle** : Agit à chaque tick serveur ou selon sa fréquence définie. +- **Ressources** : Utilise les ressources du zoo (pièces) selon les seuils du profil. +- **Indicateurs** : Visible sur la carte du monde (Pièces, Parcelle). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "integer", + "family": "enum", + "name": "string", + "logic": { + "buy_food_threshold": "float (0-1)", + "sell_animal_threshold": "float (price factor)", + "expand_map_priority": "int (0-10)", + "research_priority": "int (0-10)" + } +} +``` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Activation par joueur (déconnexion) ou Spawn serveur (Bot permanent). + +### Conditions de Disparition +Connexion joueur. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Fréquence Action** : 1 action toutes les 5 à 60 minutes (selon profil). + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Action Bot** : Achat/Vente automatisé. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function runBotLogic(bot, zoo): + if zoo.foodStock < bot.logic.buy_food_threshold: + buyFood(zoo) + return + + if bot.family == 'BREEDER': + optimizePairs(zoo) + + if bot.family == 'TRADER': + checkMarketArbitrage(zoo) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `BOT_ACTIVE` | Info | "Le mode automatique est activé." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Sélection du Profil (Configuration) +**Description UX** : Le joueur choisit comment son zoo sera géré en son absence. +**Description UI** : Interface à deux niveaux. 1. Choix Famille (5 grosses cartes illustrées). 2. Choix Spécialisation (Liste déroulante ou grille de sous-profils). +**Emplacement** : Profil Joueur -> Onglet Absence. +**Intégration** : Modal dédiée. +**Navigation** : Clic Famille -> Clic Spécialisation -> Valider. +**Événements** : `SELECT_BOT_PROFILE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `card_flip.mp3`. +- **Graphiques** : Illustrations des familles (ex: Conservateur avec bouclier, Commerçant avec bourse). +- **Images** : Portraits Bots. +- **Vidéos** : N/A +- **Animations** : Flip de carte au choix. +- **Couleurs** : Code couleur par famille (Vert=Conservateur, Jaune=Commerçant, etc.). +- **Textes** : Titres et Descriptions courtes. +- **Formes** : Cartes rectangulaires. + +### Feedback Activité (Passif) +**Description UX** : Le joueur revient et voit ce que le bot a fait. +**Description UI** : Rapport de connexion ("Pendant votre absence, le bot a : Vendu 2 lapins, Acheté 100 nourriture"). +**Emplacement** : Écran de connexion (Popup). +**Intégration** : Bloquant au démarrage. +**Navigation** : Lire -> Fermer. +**Événements** : `SHOW_REPORT`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `notification.mp3`. +- **Graphiques** : Icône Robot. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Gris/Métal. +- **Textes** : Liste des actions. +- **Formes** : Liste à puces. diff --git a/docs/specs/boutique.md b/docs/specs/boutique.md new file mode 100644 index 0000000..743a102 --- /dev/null +++ b/docs/specs/boutique.md @@ -0,0 +1,196 @@ +# Spécifications : Boutique + +## Définition +Bâtiment commercial générant des revenus passifs grâce aux visiteurs. + +## Fonctionnalités +- **Vente** : Vend des produits (souvenirs, nourriture, boissons) aux visiteurs. +- **Attraction** : Attire les visiteurs sur sa case. +- **Rétention** : Augmente la durée de visite (satisfaction besoins). + +## Niveaux d'Amélioration (7 niveaux) +- **Coût** : Progressif par palier. +- **Effet** : Augmente le revenu par visiteur et la capacité d'accueil simultanée. +- **Ratio** : 1 unité couvre 5 visiteurs simultanés max. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "shop", + "level": "integer (1-7)", + "position": { "x": "int", "y": "int" }, + "stats": { + "revenue_total": "integer", + "visitors_served": "integer" + } +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale (Niv 1) | +| :--- | :--- | +| Revenu / Visiteur | 15 pièces | +| Capacité | 5 visiteurs | +| Attractivité | 10 | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Construction par le joueur sur case vide. + +### Conditions de Disparition +Destruction par le joueur (Remboursement partiel ?). + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +| Saison | Modificateur Ventes | +| :--- | :--- | +| Été | +20% (Boissons/Glaces) | +| Hiver | +10% (Chocolat chaud/Cadeaux) | + +### Impact Heure / Jour-Nuit +| Heure | Fréquentation | +| :--- | :--- | +| 12h-14h | Pic (Repas) | +| 16h-18h | Pic (Souvenirs avant départ) | + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +| Niveau | Rayon Attraction | +| :--- | :--- | +| 1 | 2 cases | +| 7 | 10 cases | + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Vitesse Service** : 1 visiteur / 5 secondes (Niv 1) -> 1 visiteur / 1 seconde (Niv 7). + +### Dépenses (Boutiques/Visiteurs) +Génère du revenu, ne coûte rien (sauf achat/upgrade). + +### Trajet Visiteurs +Point d'intérêt majeur pour le pathfinding (Poids 50). + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Vente** : Gain de pièces. +* **File d'attente** : Si Capacité atteinte, visiteurs frustrés. + +## 7. Progression +### Tableau des Upgrades +| Niveau | Coût (Pièces) | Revenu/Visiteur | Capacité | Temps Construction | +| :--- | :--- | :--- | :--- | :--- | +| 1 | 100 | 15 | 5 | 0s | +| 2 | 150 | 20 | 8 | 0s | +| 3 | 225 | 25 | 12 | 0s | +| 4 | 340 | 35 | 18 | 0s | +| 5 | 510 | 50 | 25 | 0s | +| 6 | 765 | 70 | 35 | 0s | +| 7 | 1150 | 100 | 50 | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function processShop(shop, visitors): + queue = visitors.filter(v => v.target == shop) + served = min(queue.length, shop.capacity) + + for i in 0..served: + revenue = shop.baseRevenue * seasonMod + player.addCoins(revenue) + visitors[i].satisfaction += 5 + visitors[i].needs.thirst = 0 +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `SHOP_FULL` | Warning | "Boutique pleine ! Des clients partent." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Vente (Passif) +**Description UX** : Les visiteurs entrent dans la boutique, y restent quelques secondes, puis ressortent. Des pièces volent vers le compteur. +**Description UI** : Animation de porte qui s'ouvre/ferme. Popups "+15" au-dessus du toit. +**Emplacement** : Case Boutique. +**Intégration** : Automatique. +**Navigation** : N/A +**Événements** : `SALE_MADE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `coin_jingle.mp3` (volume bas, pas trop répétitif). +- **Graphiques** : Sprite Boutique (différents skins selon type : Burger, Cadeaux). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Pièce dorée qui monte et disparaît. +- **Couleurs** : Or #FFD700. +- **Textes** : "+XX". +- **Formes** : N/A + +### Consultation Stats (Consultation) +**Description UX** : Le joueur clique pour voir combien la boutique a rapporté. +**Description UI** : **Bottom Sheet** simple avec "Revenus totaux" et "Clients servis". +**Emplacement** : Case Boutique. +**Intégration** : Standard. +**Navigation** : Clic -> Modal. +**Événements** : `OPEN_SHOP`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `ui_open.mp3` +- **Graphiques** : N/A +- **Images** : Icône Caisse. +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : N/A +- **Textes** : Chiffres clés. +- **Formes** : N/A + +### Construction (Action) +**Description UX** : Le joueur choisit "Boutique" dans le menu construction et la place sur la grille. +**Description UI** : Fantôme du bâtiment vert/rouge selon validité. +**Alternative** : Tap to Select -> Tap to Place. +**Emplacement** : Menu Construction -> Grille. +**Intégration** : Mode construction global. +**Navigation** : Clic Menu -> Drag/Click Grille. +**Événements** : `BUILD_SHOP`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `construction_hammer.mp3`. +- **Graphiques** : Sprite Fantôme semi-transparent. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Petit effet de poussière à la pose. +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/camion.md b/docs/specs/camion.md new file mode 100644 index 0000000..323f2b7 --- /dev/null +++ b/docs/specs/camion.md @@ -0,0 +1,219 @@ +# Spécifications : Camion + +## Définition +Véhicule de transport pour les achats et les ventes d'animaux/œufs. + +## Fonctionnalités +- **Transport** : Effectue les trajets entre le zoo du joueur et les autres sites (zoos, labo) sur la Carte du Monde. +- **Vente** : Zone de dépôt sur la carte du zoo pour mettre un animal en vente. +- **Achat** : Zone de réception sur la carte du monde pour acheter une offre. + +## Comportement +- **Trajet** : Aller-retour physique sur la carte du monde. La durée dépend de la distance et du niveau du camion. +- **Capacité** : Transporte un animal/œuf à la fois (par unité de camion). + +## Niveaux d'Amélioration (7 niveaux) +- **Coût** : Progressif par palier. +- **Effet** : Augmente la vitesse de déplacement. Réduit la dégradation du score de reproduction due au transport (stress). +- **Ratio** : 1 unité couvre 1 camion actif. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "owner_id": "uuid", + "level": "integer (1-7)", + "status": "enum ('idle', 'moving_to_target', 'moving_home')", + "cargo": "animal_uuid | null", + "position": { "x": "float", "y": "float" }, + "target_site_id": "uuid | null" +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale (Niv 1) | +| :--- | :--- | +| Vitesse | 50 km/h (simulé) | +| Confort | Basique (Stress normal) | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Démarrage jeu. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +**Hiver** : Vitesse réduite de 20% (Neige). + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +**Stress Transport** : Réduit temporairement la fertilité de l'animal transporté. +| Niveau Camion | Perte Fertilité / Heure Trajet | +| :--- | :--- | +| 1 | -10% | +| 7 | -1% | + +### Impact Mort +Si trajet trop long (> 24h) sans confort, risque de mort (Faim/Stress). + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Vitesse Déplacement** : +| Niveau | Vitesse | +| :--- | :--- | +| 1 | 50 km/h | +| 2 | 70 km/h | +| 3 | 90 km/h | +| 4 | 110 km/h | +| 5 | 130 km/h | +| 6 | 150 km/h | +| 7 | 200 km/h (Avion/Hélico ?) | + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Départ** : Camion part. +* **Arrivée** : Camion revient avec marchandise. + +## 7. Progression +### Tableau des Upgrades +| Niveau | Coût (Pièces) | Vitesse | Confort | Temps Construction | +| :--- | :--- | :--- | :--- | :--- | +| 1 | 100 | 50 | Bas | 0s | +| 2 | 200 | 70 | Moyen | 0s | +| 3 | 400 | 90 | Moyen | 0s | +| 4 | 800 | 110 | Bon | 0s | +| 5 | 1600 | 130 | Bon | 0s | +| 6 | 3200 | 150 | Excellent | 0s | +| 7 | 6400 | 200 | Parfait | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function updateTruck(truck, deltaTime): + if truck.status == 'moving': + distance = truck.speed * deltaTime + moveTowardsTarget(truck, distance) + + if truck.cargo: + truck.cargo.stress += baseStress * (1.0 - truck.comfortFactor) * deltaTime +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `TRUCK_ARRIVED` | Info | "Le camion est revenu au zoo." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Envoi en Mission (Action) +**Description UX** : Le joueur achète un animal sur la carte du monde. Le camion part automatiquement le chercher. +**Description UI** : Le camion quitte sa case au zoo (animation départ) et apparaît sur la carte du monde, se déplaçant vers la cible. +**Emplacement** : Zoo -> Carte Monde. +**Intégration** : Transition fluide. +**Navigation** : Achat -> Vue Carte Monde (auto ou manuelle). +**Événements** : `TRUCK_DEPART`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `engine_start.mp3`, `horn.mp3`. +- **Graphiques** : Sprite Camion (Vue dessus et côté). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Roues qui tournent, fumée échappement. +- **Couleurs** : Jaune/Noir (Chantier) ou Blanc (Livraison). +- **Textes** : "En route vers [Destination]". +- **Formes** : N/A + +### Suivi Trajet (Passif/Actif) +**Description UX** : Le joueur peut suivre le camion sur la carte du monde. +**Description UI** : Icône camion qui bouge sur la ligne de trajet. Timer estimé au survol. +**Emplacement** : Carte Monde. +**Intégration** : Overlay. +**Navigation** : Clic Camion -> Info Trajet. +**Événements** : `TRACK_TRUCK`. + +#### Assets +- **Musiques** : N/A +- **Sons** : Bruit moteur continu (faible). +- **Graphiques** : Ligne pointillée (trajet). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Déplacement linéaire. +- **Couleurs** : Ligne Blanche. +- **Textes** : "Arrivée dans MM:SS". +- **Formes** : N/A + +### Activité d'Attente (Gameplay) +**Description UX** : Pendant le trajet (temps mort), le joueur peut cliquer sur le camion pour le "booster" ou nettoyer le pare-brise (mini-jeu simple) pour gagner quelques secondes ou de l'XP. +**Description UI** : Zoom sur le camion. Taches apparaissent, clic pour nettoyer. +**Emplacement** : Modal Camion (en trajet). +**Intégration** : Mini-jeu optionnel. +**Navigation** : Clic Camion -> Mini-jeu. +**Événements** : `CLEAN_WINDSHIELD`. + +#### Assets +- **Musiques** : Radio camion (musique lo-fi). +- **Sons** : `wipe.mp3` (essuie-glace). **Design ASMR** : Bruit d'éponge mouillée ou de raclette satisfaisant. +- **Graphiques** : Taches de boue/insectes. +- **Images** : Vue cockpit simplifiée. +- **Vidéos** : N/A +- **Animations** : Essuie-glace qui bouge. +- **Couleurs** : N/A +- **Textes** : "Boost !" +- **Formes** : N/A + +### Arrivée (Passif) +**Description UX** : Le camion revient au zoo et se gare. +**Description UI** : Animation d'arrivée. Notification "Camion rentré". +**Notification Push** : "Le camion est de retour !" (si application fermée). +**Emplacement** : Case Camion. +**Intégration** : Fin cycle. +**Navigation** : N/A +**Événements** : `TRUCK_ARRIVE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `truck_stop.mp3`. +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Freinage. +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/carte_generique.md b/docs/specs/carte_generique.md new file mode 100644 index 0000000..547b4bf --- /dev/null +++ b/docs/specs/carte_generique.md @@ -0,0 +1,122 @@ +# Spécifications : Carte (Générique) + +## Définition +Une carte est une grille de cases représentant un espace de jeu ou de navigation. + +## Propriétés +- **Dimensions** : Largeur et hauteur définies en nombre de cases. +- **Biomes** : Les cases ont une couleur de fond représentant leur biome (milieu). +- **Quadrillage** : Les cases sont disposées en grille orthogonale. +- **Cohérence** : Les transitions de couleurs (milieux) et de températures doivent être douces entre les cases adjacentes. + +## Types de cartes +- Carte du Zoo (Espace de gestion du joueur) +- Carte du Monde (Espace de navigation multijoueur) + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "enum ('zoo', 'world')", + "width": "integer", + "height": "integer", + "grid": [ + [ { "case_ref": "case_id" } ] + ] +} +``` + +### Caractéristiques Initiales +N/A (Dépendant du type) + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Création à l'initialisation du jeu/compte. + +### Conditions de Disparition +Suppression du compte. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A (Porté par les cases) + +### Impact Milieu (Biome) +N/A (Porté par les cases) + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Navigation (Action) +**Description UX** : Le joueur explore la carte. +**Description UI** : Drag & Drop (Pan) pour se déplacer. Zoom In/Out (Molette/Pinch). +**Emplacement** : Vue Principale. +**Intégration** : Plein écran. +**Navigation** : Pan/Zoom. +**Événements** : `MAP_MOVE`, `MAP_ZOOM`. + +#### Assets +- **Musiques** : Ambiance calme (Zoo) / Aventure (Monde). +- **Sons** : Vent léger. +- **Graphiques** : Grille (Grid lines) subtile. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Inertie du mouvement. +- **Couleurs** : Fond neutre hors carte. +- **Textes** : Coordonnées (Debug). +- **Formes** : Rectangulaire. diff --git a/docs/specs/carte_monde.md b/docs/specs/carte_monde.md new file mode 100644 index 0000000..7bc2cbe --- /dev/null +++ b/docs/specs/carte_monde.md @@ -0,0 +1,162 @@ +# Spécifications : Carte du Monde + +## Définition +La carte du monde est l'espace multijoueur où sont situés les zoos des joueurs, des bots, les villes et les laboratoires. + +## Structure +- **Grille** : Fixe, avec des positions définies pour chaque entité. +- **Fond** : Reprend les biomes de la carte du zoo (Prairie/Océan/Montagne) en fond. +- **Zoom** : Niveau de zoom dépendant de l'upgrade "Agrandir la carte". Pas de pan manuel. + +## Contenu Initial (Lancement) +- 1 Case Agrandissement de la carte (Payable en unités de recherche) +- 1 Compteur de bébés à vendre +- 1 Compteur d'animaux à vendre +- 1 Compteur de laboratoires +- 1 Compteur de zoos +- 1 Compteur de villes +- 1 Case Accueil nouveaux animaux (Haut gauche) +- 1 Case Nourriture générale (Haut gauche) +- 1 Case Camion (Haut gauche) +- 24 Cases de terrain (3 couleurs différentes) + +## Entités Visibles +- **Zoos Joueurs** : Nom, icône, indicateurs. +- **Zoos Bots** : Identiques aux joueurs. +- **Villes** : Sources de visiteurs. +- **Laboratoires** : Sources d'œufs/bébés rares. +- **Camions** : En transit entre les entités. + +## Mécaniques +- **Navigation** : Vue d'ensemble du marché. +- **Interactions** : Achat d'œufs/animaux via le camion. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "world_map", + "width": "integer (très grand)", + "height": "integer", + "sites": [ { "id": "uuid", "type": "zoo|city|lab", "pos": { "x": "int", "y": "int" } } ], + "trucks": [ { "id": "uuid", "owner_id": "uuid", "pos": { "x": "float", "y": "float" }, "target": "uuid" } ] +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Rayon Visible | 500km (simulé) | +| Nb Villes | 10 (dans rayon départ) | +| Nb Labos | 2 (dans rayon départ) | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Unique (Singleton serveur). + +### Conditions de Disparition +Jamais. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +Calcul des distances Ville -> Zoo pour l'attractivité. + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Spawn Bot** : Apparition nouveau zoo bot. +* **Spawn Truck** : Camion visible. + +## 7. Progression +### Tableau des Upgrades +Voir `agrandissement_carte.md`. + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Vue Globale (Passif) +**Description UX** : Le joueur voit les autres acteurs et les flux économiques. +**Description UI** : Carte style "Google Maps" simplifiée ou grille hexagonale. Icônes (Maisons, Gratte-ciels, Fioles). **Flux** : Petites particules (voitures/points) voyageant entre les villes et les zoos attractifs. +**Emplacement** : Écran Carte Monde. +**Intégration** : Plein écran. +**Navigation** : Bouton Retour Zoo. +**Événements** : `OPEN_WORLD_MAP`. + +#### Assets +- **Musiques** : Thème Aventure/Voyage. +- **Sons** : Vent, Bruit lointain trafic. +- **Graphiques** : Fond carte (Biomes flous). +- **Images** : Icônes Sites. +- **Vidéos** : N/A +- **Animations** : Camions qui bougent. +- **Couleurs** : Desaturées (pour faire ressortir les icônes). +- **Textes** : Noms des villes/zoos. +- **Formes** : Pins/Marqueurs. + +### Brouillard de Guerre (Passif) +**Description UX** : Les zones inexplorées sont cachées. +**Description UI** : Nuages ou zone sombre en périphérie. +**Emplacement** : Bords Carte. +**Intégration** : Mask. +**Navigation** : N/A +**Événements** : N/A + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Texture Nuages. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Nuages qui dérivent lentement. +- **Couleurs** : Blanc/Gris. +- **Textes** : "Zone Inexplorée". +- **Formes** : N/A diff --git a/docs/specs/carte_zoo.md b/docs/specs/carte_zoo.md new file mode 100644 index 0000000..1ab2887 --- /dev/null +++ b/docs/specs/carte_zoo.md @@ -0,0 +1,166 @@ +# Spécifications : Carte du Zoo + +## Définition +La carte du zoo est l'espace principal de gestion du joueur où il place ses bâtiments et ses animaux. + +## Structure +- **Grille** : Composée de cases individuelles. +- **Biomes** : Divisée en tiers de largeur : + - Gauche : Prairie + - Milieu : Océan + - Droite : Montagne +- **Dégradés** : Couleurs et températures varient doucement au sein des biomes. + +## Contenu Initial (Lancement) +- 1 Case Agrandissement du zoo (+1 case, payant) +- 1 Case Recherche (Coin haut gauche) +- 1 Case Billeterie (Coin haut gauche) +- 1 Case Nurserie (Coin haut gauche) +- 1 Case Accueil nouveaux animaux (Coin haut gauche) +- 1 Case Nourriture générale (Coin haut gauche) +- 1 Case Camion (Coin haut gauche) +- 24 Cases de terrain (3 couleurs différentes réparties selon les biomes) + +## Mécaniques +- **Placement** : Le joueur peut placer des œufs, des animaux et des bâtiments sur les cases vides. +- **Déplacement** : Glisser-déposer pour réorganiser. +- **Visiteurs** : Les visiteurs se déplacent sur la grille. +- **Feedbacks** : L'état du terrain (herbe jaunie, givre) reflète l'adéquation avec les animaux. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "owner_id": "uuid", + "width": "integer (init 5)", + "height": "integer (init 6)", + "cases": [ ... ] +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Taille | 30 cases (5x6 approx) | +| Biomes | 3 (Tiers) | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Création du compte joueur. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A (Géré par case) + +### Impact Milieu (Biome) +N/A (Géré par case) + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +Support du pathfinding (A* sur la grille). + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Agrandissement** : Ajout d'une colonne/ligne ou case adjacente. + +## 7. Progression +### Tableau des Upgrades +Voir `achat_upgrade_case_zoo.md`. + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function initZooMap(): + width = 6 + height = 5 + for x in 0..width: + for y in 0..height: + biome = getBiomeByColumn(x, width) + createCase(x, y, biome) +``` + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Interaction Grille (Action) +**Description UX** : Le joueur clique sur une case pour voir son contenu ou construire. +**Description UI** : Surbrillance de la case au survol. Clic ouvre un **Panneau Latéral (Bottom Sheet)** non bloquant (Info/Construire). +**Emplacement** : Grille. +**Intégration** : Curseur. +**Navigation** : Clic -> Menu. +**Événements** : `CLICK_CASE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `click_tile.mp3`. +- **Graphiques** : Cadre de sélection blanc/brillant. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Pulsation du cadre. +- **Couleurs** : Blanc. +- **Textes** : N/A +- **Formes** : Carré. + +### Mode Construction (Action) +**Description UX** : Le joueur place un bâtiment ou modifie le terrain. +**Description UI** : Grille visible. Cases valides en vert, invalides en rouge. +**Mode Peinture** : Le joueur peut glisser son doigt (Drag) pour construire/modifier plusieurs cases d'affilée (ex: peindre un biome ou poser une route). +**Alternative Accessibilité** : Tap to Select (Bâtiment) -> Tap to Place (Case) pour éviter le Drag & Drop. +**Emplacement** : Grille. +**Intégration** : Overlay. +**Navigation** : Drag -> Drop. +**Événements** : `ENTER_BUILD_MODE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `grid_snap.mp3`. +- **Graphiques** : Grille semi-transparente. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Snap to grid. +- **Couleurs** : Vert/Rouge (Alpha 0.5). +- **Textes** : N/A +- **Formes** : Carrés. diff --git a/docs/specs/case_generique.md b/docs/specs/case_generique.md new file mode 100644 index 0000000..bdb87b2 --- /dev/null +++ b/docs/specs/case_generique.md @@ -0,0 +1,122 @@ +# Spécifications : Case (Générique) + +## Définition +Une case est l'unité élémentaire de la grille (Zoo ou Monde). + +## Propriétés +- **Coordonnées** : Position (x, y) dans la grille. +- **Couleur (Milieu)** : Définit le biome et l'environnement visuel. +- **Température** : Liée à la couleur/biome. +- **Contenu** : Peut contenir un bâtiment, un animal, un visiteur, ou être vide. +- **État** : Peut avoir des indicateurs visuels (herbe jaunie, givre). + +## Règles +- Les transitions de propriétés (couleur, température) entre cases adjacentes doivent être douces. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "x": "integer", + "y": "integer", + "biome_id": "integer", + "temperature_offset": "float", + "content_ref": "uuid | null", + "content_type": "string", + "state_flags": ["frozen", "dry", "dirty"] +} +``` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Génération carte. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +`Température_Réelle = Température_Biome + Température_Offset + Saison_Mod` + +### Impact Milieu (Biome) +Définit la couleur de fond. + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Feedback État (Passif) +**Description UX** : La case montre son état physique. +**Description UI** : Overlay de texture (Givre, Craquelures, Boue). +**Emplacement** : Case. +**Intégration** : Layer 1 (Au-dessus du fond, sous le contenu). +**Navigation** : N/A +**Événements** : `STATE_UPDATE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Textures alpha (Givre blanc, Craquelures marron). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Apparition progressive (Fade). +- **Couleurs** : Blanc, Marron, Gris. +- **Textes** : N/A +- **Formes** : Carré. diff --git a/docs/specs/case_monde.md b/docs/specs/case_monde.md new file mode 100644 index 0000000..3f50a6f --- /dev/null +++ b/docs/specs/case_monde.md @@ -0,0 +1,112 @@ +# Spécifications : Case de Monde (Générique) + +## Définition +Case spécifique à la grille du monde, servant de support aux sites et aux compteurs. + +## Types de Contenu Possibles +- **Sites** : Zoo (Joueur/Bot), Ville, Laboratoire. +- **Compteurs** : Bébés à vendre, Animaux à vendre, Labos, Zoos, Villes. +- **Zones Fonctionnelles** : Agrandissement carte, Camion (zone de dépôt/vente). + +## Propriétés Spécifiques +- **Fixité** : Le contenu des cases monde est généralement fixe (sites) ou fonctionnel (zones d'interface). +- **Navigation** : Les camions naviguent de case en case entre les sites. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Hérite de `Case (Générique)`. +Pas de propriétés supplémentaires spécifiques. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Génération monde. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Survol (Passif) +**Description UX** : Explorer la carte du monde. +**Description UI** : Curseur main. Tooltip au survol des sites. +**Emplacement** : Carte Monde. +**Intégration** : Standard. +**Navigation** : Mouse move. +**Événements** : `HOVER_WORLD_CASE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/case_zoo.md b/docs/specs/case_zoo.md new file mode 100644 index 0000000..44c817b --- /dev/null +++ b/docs/specs/case_zoo.md @@ -0,0 +1,135 @@ +# Spécifications : Case de Zoo (Générique) + +## Définition +Case spécifique à la grille du zoo, pouvant accueillir les éléments de gestion. + +## Types de Contenu Possibles +- **Bâtiments** : Billeterie, Nurserie, Boutique, Centre de Recherche, École, Accueil Animaux. +- **Animaux** : Bébés matures, Animaux adultes. +- **Éléments Mobiles** : Visiteurs, Camion (sur sa zone dédiée). +- **Vide** : Terrain libre pour construction ou placement. + +## Propriétés Spécifiques +- **Occupation** : Une case ne peut contenir qu'un seul élément principal (bâtiment/animal) à la fois. +- **Visite** : Enregistre la date de dernier passage d'un visiteur (pour la règle de disparition). +- **Qualité** : Indicateur visuel d'adéquation avec l'animal posé dessus (Jaunissement, Givre). + +## Liste des Cases Spéciales (cf. Carte Zoo) +- Agrandissement +- Recherche +- Billeterie +- Nurserie +- Accueil +- Nourriture +- Camion + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Hérite de `Case (Générique)`. +```json +{ + "last_visit_time": "timestamp", + "quality_score": "float (0-100)" +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Dernière Visite | Création du zoo | +| Qualité | 100 | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Génération zoo. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Voir `case_generique.md`. + +### Impact Milieu (Biome) +Voir `case_generique.md`. + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Passage Visiteur** : Met à jour `last_visit_time`. + +## 7. Progression +### Tableau des Upgrades +Voir `achat_upgrade_case.md`. + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function checkAnimalDisappearance(case): + if currentTime - case.last_visit_time > MAX_TIME_WITHOUT_VISIT: + removeAnimal(case.content) +``` + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Sélection (Action) +**Description UX** : Clic sur une case zoo. +**Description UI** : Highlight + Menu contextuel adapté au contenu (Animal -> Stats, Bâtiment -> Upgrade, Vide -> Construire). +**Emplacement** : Grille. +**Intégration** : Standard. +**Navigation** : Clic. +**Événements** : `SELECT_ZOO_CASE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `select_soft.mp3`. +- **Graphiques** : Cadre sélection. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Blanc. +- **Textes** : N/A +- **Formes** : Carré. diff --git a/docs/specs/centre_recherche.md b/docs/specs/centre_recherche.md new file mode 100644 index 0000000..23cab4c --- /dev/null +++ b/docs/specs/centre_recherche.md @@ -0,0 +1,190 @@ +# Spécifications : Centre de Recherche + +## Définition +Bâtiment produisant des points de science/recherche. + +## Fonctionnalités +- **Production** : Génère des unités de recherche périodiquement. +- **Déblocage** : Permet de payer l'agrandissement de la carte du monde. +- **Visibilité** : Donne accès aux niveaux d'animaux/bébés supérieurs dans les autres zoos (débloque la vue sur les offres rares). + +## Niveaux d'Amélioration (7 niveaux) +- **Coût** : Progressif par palier. +- **Effet** : Augmente la production et le niveau de visibilité des offres externes. +- **Ratio** : 1 unité couvre la visibilité sur 10 zoos définis (par proximité). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "research_center", + "level": "integer (1-7)", + "stats": { + "points_generated": "integer", + "last_collection": "timestamp" + } +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale (Niv 1) | +| :--- | :--- | +| Production | 10 pts / heure | +| Portée Radar | 10 zoos voisins | +| Rareté Visible | Niveau 1 (Commun) | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Présent au démarrage. + +### Conditions de Disparition +Indestructible (ou un seul obligatoire). + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +Production continue 24h/24. + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Vitesse Recherche** : +| Niveau | Points / Heure | +| :--- | :--- | +| 1 | 10 | +| 2 | 20 | +| 3 | 40 | +| 4 | 80 | +| 5 | 150 | +| 6 | 300 | +| 7 | 600 | + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Découverte** : Déblocage d'une nouvelle zone carte monde. + +## 7. Progression +### Tableau des Upgrades +| Niveau | Coût (Pièces) | Production | Portée (Zoos) | Rareté Visible | Temps Construction | +| :--- | :--- | :--- | :--- | :--- | :--- | +| 1 | 0 | 10 | 10 | 1 | 0s | +| 2 | 300 | 20 | 20 | 2 | 0s | +| 3 | 600 | 40 | 30 | 2 | 0s | +| 4 | 1200 | 80 | 50 | 3 | 0s | +| 5 | 2400 | 150 | 80 | 3 | 0s | +| 6 | 4800 | 300 | 120 | 4 | 0s | +| 7 | 9600 | 600 | Tout | 5 | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function updateResearch(center): + points = center.productionRate * deltaTime + player.addResearchPoints(points) + + visibleRarity = center.maxRarityVisible + updateWorldMapVisibility(center.range, visibleRarity) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `RESEARCH_UNLOCK` | Info | "Nouvelle zone de carte découverte !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Collecte de Points (Action) +**Description UX** : Les points de recherche s'accumulent. Le joueur clique sur le bâtiment pour les récolter (ou collecte auto si implémenté). +**Description UI** : Une icône "Fiole" ou "Atome" flotte au-dessus du bâtiment quand des points sont disponibles. Au clic, les points volent vers le compteur global. +**Emplacement** : Case Centre Recherche. +**Intégration** : Overlay au-dessus du sprite. +**Navigation** : Clic Bâtiment -> Collecte. +**Événements** : `COLLECT_RESEARCH`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `bubble_pop.mp3` ou `sci_fi_bloop.mp3`. **Design ASMR** : "Bloop" liquide ou tintement cristallin. +- **Graphiques** : Icône Fiole bleue/violette. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Flottement (bobbing) de l'icône. Trajectoire courbe des points vers le HUD. +- **Couleurs** : Bleu Science #0088FF. +- **Textes** : "+10 RP". +- **Formes** : Cercle ou Fiole. + +### Consultation & Arbre Techno (Consultation) +**Description UX** : Le joueur veut voir sa production et débloquer des zones. +**Description UI** : **Bottom Sheet** affichant "Production : X/h". Un bouton "Carte du Monde" pour voir l'effet du radar. +**Emplacement** : Modal Centre Recherche. +**Intégration** : Lien vers la Carte du Monde. +**Navigation** : Clic Bâtiment (si pas de collecte ou double clic) -> Modal. +**Événements** : `OPEN_RESEARCH`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `ui_open.mp3` +- **Graphiques** : Style High-Tech / Labo. +- **Images** : `radar_icon.png`. +- **Vidéos** : N/A +- **Animations** : Radar qui balaie (décoratif). +- **Couleurs** : Blanc, Gris, Bleu néon. +- **Textes** : "Portée Radar : X km". +- **Formes** : Panneaux anguleux. + +### Amélioration (Upgrade) +**Description UX** : Augmenter la production et la portée. +**Description UI** : Bouton standard d'upgrade avec comparaison "Actuel vs Suivant". +**Emplacement** : Modal Centre Recherche. +**Intégration** : Standard. +**Navigation** : Clic Upgrade. +**Événements** : `UPGRADE_RESEARCH`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `upgrade_tech.mp3` (son électronique). +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Flash bleu sur le bâtiment. +- **Couleurs** : Bleu. +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/enchere_generique.md b/docs/specs/enchere_generique.md new file mode 100644 index 0000000..e958bd0 --- /dev/null +++ b/docs/specs/enchere_generique.md @@ -0,0 +1,142 @@ +# Spécifications : Enchère (Générique) + +## Définition +Mécanisme de vente entre joueurs/bots sur la Carte du Monde. + +## Fonctionnement +1. **Mise en vente** : Le vendeur place un animal/bébé sur la case Camion/Vente de son zoo. Définit un prix initial. +2. **Offre** : Visible sur la Carte du Monde (slot sous le zoo). +3. **Enchères** : Les acheteurs (joueurs/bots) proposent des prix. +4. **Validation** : Le vendeur accepte une offre ou laisse courir. +5. **Sécurisation** : Une fois l'offre acceptée/validée, un **délai de 10 minutes** (Sablier) s'enclenche. +6. **Finalisation** : À la fin du sablier, le transfert de propriété et de fonds est effectué (anti-race condition). + +## Types +- Vente de Bébés +- Vente d'Animaux Adultes + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "seller_id": "uuid", + "animal_ref": "uuid", + "start_price": "integer", + "current_bid": "integer", + "highest_bidder_id": "uuid | null", + "end_time": "timestamp", + "status": "enum ('active', 'pending_validation', 'finalizing', 'completed', 'cancelled')", + "finalization_timer": "timestamp (end)" +} +``` + +### Caractéristiques Initiales +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function placeBid(auction, bidder, amount): + if amount <= auction.current_bid: return Error + if bidder.coins < amount: return Error + + auction.current_bid = amount + auction.highest_bidder_id = bidder.id + +function finalizeAuction(auction): + if now < auction.finalization_timer: return + + transferCoins(auction.highest_bidder_id, auction.seller_id, auction.current_bid) + transferAnimal(auction.seller_id, auction.highest_bidder_id, auction.animal_ref) + auction.status = 'completed' +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `BID_PLACED` | Info | "Offre placée avec succès." | +| `OUTBID` | Alerte | "Vous avez été surenchéri !" | +| `AUCTION_WON` | Succès | "Vous avez remporté l'enchère ! Livraison en cours..." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Consultation des Offres (Consultation) +**Description UX** : Le joueur survole les zoos sur la carte du monde pour voir les offres. +**Description UI** : Tooltip ou Slot sous le zoo affichant l'icône de l'animal et le prix actuel. +**Emplacement** : Carte Monde. +**Intégration** : Overlay Zoo. +**Navigation** : Survol -> Info. +**Événements** : `HOVER_OFFER`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `hover_soft.mp3`. +- **Graphiques** : Cadre Offre (Doré si rare). +- **Images** : Icône Animal. +- **Vidéos** : N/A +- **Animations** : Prix qui clignote si nouvelle enchère. +- **Couleurs** : Vert (Abordable), Rouge (Trop cher). +- **Textes** : "Lapin Rare - 500$". +- **Formes** : Rectangle arrondi. + +### Enchérir (Action) +**Description UX** : Le joueur clique sur une offre pour ouvrir le détail et placer une enchère. +**Description UI** : **Bottom Sheet** d'enchère. Infos complètes de l'animal. Champ de saisie "Votre offre" ou boutons rapides (+10, +50, +100). +**Emplacement** : Modal Enchère. +**Intégration** : Modal centrée. +**Navigation** : Clic Offre -> Modal -> Enchérir. +**Événements** : `PLACE_BID`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `bid_placed.mp3` (bruit de marteau). +- **Graphiques** : Icône Marteau. +- **Images** : Portrait Animal HD. +- **Vidéos** : N/A +- **Animations** : Compteur prix qui monte. +- **Couleurs** : Bouton Vert. +- **Textes** : "Enchérir". +- **Formes** : N/A + +### Sablier de Validation (Attente) +**Description UX** : L'enchère est remportée, le transfert est en cours (10 min). +**Description UI** : Icône Sablier sur l'offre et dans le HUD de l'acheteur. +**Notification Push** : "Enchère remportée ! Animal livré." à la fin du timer. +**Emplacement** : Carte Monde & HUD. +**Intégration** : Statut "En cours". +**Navigation** : N/A +**Événements** : `TIMER_START`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `tick_tock.mp3` (ambiance très discrète si focus). +- **Graphiques** : Sablier animé. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Sable qui coule. +- **Couleurs** : Orange. +- **Textes** : "Validation : MM:SS". +- **Formes** : N/A + +### Activité "Clic" (Gameplay) +**Description UX** : Pendant les 10 min, le joueur peut cliquer sur le sablier pour "accélérer" (symboliquement) ou gagner un petit bonus (XP/Pièce) pour patienter. +**Description UI** : Le sablier réagit au clic (secousse). +**Emplacement** : Modal Validation. +**Intégration** : Mini-jeu. +**Navigation** : Clic Sablier. +**Événements** : `CLICK_HOURGLASS`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `sand_shake.mp3`. **Design ASMR** : Bruit de sable qui crisse ou petit choc mat. +- **Graphiques** : Particules de sable. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Secousse. +- **Couleurs** : N/A +- **Textes** : "+1 XP". +- **Formes** : N/A diff --git a/docs/specs/etat.md b/docs/specs/etat.md new file mode 100644 index 0000000..064faa7 --- /dev/null +++ b/docs/specs/etat.md @@ -0,0 +1,120 @@ +# Spécifications : États (Feedbacks Visuels) + +## Définition +Indicateurs visuels permettant au joueur de comprendre l'état de ses animaux et de son terrain sans utiliser de jauges (UI). + +## États des Animaux +- **Froid** : Teinte bleuâtre/pâle, givre visible sur le sprite. +- **Chaud** : Teinte rougeâtre, vapeur de chaleur visible. +- **Faim** : Déplacement lent, maigreur visible, cherche le sol, icône "faim" discrète. +- **Maladie / Mort proche** : Animal couché, couleurs ternes, mouches autour. +- **Heureux / Reproduction** : Cœurs, sautillements, couleurs vives. + +## États du Terrain +- **Mauvais Biome** : Herbe jaunie, sol craquelé ou boueux (changement de teinte global). +- **Température Inadaptée** : Sol givré (froid) ou vapeurs (chaud). +- **Saleté** : Déchets sombres, poussière grise. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Liste d'enums ou flags dans l'objet `Animal` ou `Case`. +`visual_states: ["frozen", "hungry"]` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Déclenchés par les seuils de stats (Santé, Faim, T°). + +### Conditions de Disparition +Retour à la normale des stats. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Déclencheurs Froid/Chaud. + +### Impact Milieu (Biome) +Déclencheurs Mauvais Biome. + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +Déclencheur Heureux. + +### Impact Mort +Déclencheur Maladie/Mort. + +### Impact Nourriture +Déclencheur Faim. + +### Impact Attractivité (Visiteurs/Animaux) +Les états négatifs (Sale, Malade) repoussent les visiteurs. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +if animal.temp < animal.idealTemp - 10: + addVisualState("frozen") +elif animal.temp > animal.idealTemp + 10: + addVisualState("overheat") +``` + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Lecture Immédiate (Passif) +**Description UX** : Le joueur comprend la situation d'un coup d'œil sans ouvrir de menu. +**Description UI** : Pas d'UI. Tout passe par le rendu graphique des entités. +**Emplacement** : Grille Zoo. +**Intégration** : Moteur de rendu. +**Navigation** : N/A +**Événements** : `STATE_CHANGE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Shaders (Color Overlay), Systèmes de particules. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Animations spécifiques (Trembler, Boiter). +- **Couleurs** : Code universel (Bleu=Froid, Rouge=Chaud, Vert=OK, Jaune=Malade). +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/impacts_generique.md b/docs/specs/impacts_generique.md new file mode 100644 index 0000000..2c85dd2 --- /dev/null +++ b/docs/specs/impacts_generique.md @@ -0,0 +1,69 @@ +# Spécifications : Impacts (Générique) + +## Définition +Les impacts sont les conséquences des actions du joueur ou des événements du jeu sur les scores et l'état du zoo. + +## Types d'Impacts + +### Sur l'Attractivité +- **Positif** : Naissance, Achat d'animal rare, Résolution d'incident visiteur, Diversité élevée. +- **Négatif** : Mort d'animal, Incident visiteur ignoré, Zoo vide. + +### Sur la Reproduction +- **Positif** : Animal bien nourri, Température idéale, Biome (couleur) idéal, Proximité partenaire compatible. +- **Négatif** : Faim, Température inadaptée, Mauvais biome, Stress (transport long), Morts dans le zoo. + +### Sur les Revenus +- **Positif** : Visiteurs nombreux, Boutiques améliorées, Animaux rares (revenu passif), Ventes réussies. +- **Négatif** : Coûts d'entretien (nourriture), Achats onéreux. + +### Sur la Survie +- **Positif** : Adéquation Biome/Animal, Nourriture suffisante. +- **Négatif** : Froid/Chaud extrême, Faim prolongée, Isolement. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Structure de configuration des impacts (Ruleset). +```json +{ + "impact_rules": [ + { "trigger": "event_type", "target": "score_type", "value": "float", "duration": "int" } + ] +} +``` + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function applyImpact(target, impactType, value): + if impactType == 'ATTRACTIVENESS': + target.attractiveness += value + elif impactType == 'SURVIVAL': + target.survival_score += value + + logEvent(target.id, impactType, value) +``` + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Feedback Visuel d'Impact (Passif) +**Description UX** : Le joueur voit les conséquences de ses actions. +**Description UI** : Popups flottants ("Floating Text") au-dessus des entités concernées. +**Emplacement** : World Space. +**Intégration** : Temporaire (Fade out). +**Navigation** : N/A +**Événements** : `IMPACT_APPLIED`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `positive.mp3` (montant), `negative.mp3` (descendant). +- **Graphiques** : Flèches Vertes (Haut) / Rouges (Bas). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Texte qui monte et disparaît. +- **Couleurs** : Vert, Rouge. +- **Textes** : "+10 Attractivité", "-5 Santé". +- **Formes** : N/A diff --git a/docs/specs/inventaire_animaux.md b/docs/specs/inventaire_animaux.md new file mode 100644 index 0000000..e021493 --- /dev/null +++ b/docs/specs/inventaire_animaux.md @@ -0,0 +1,122 @@ +# Spécifications : Inventaire des Animaux + +## Structure +75 Animaux répartis sur les 15 Milieux (Couleurs) et 5 Niveaux de Rareté. + +## Exemples (Extrait) +- **Prairie / Rareté 1** : Lapin +- **Prairie / Rareté 5** : Licorne +- **Océan / Rareté 1** : Poisson Clown +- **Océan / Rareté 5** : Kraken +- **Montagne / Rareté 1** : Chèvre +- **Montagne / Rareté 5** : Dragon + +*(La liste complète des 75 animaux doit être définie dans un fichier de données ou une table de loot).* + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Table statique `animal_types`. +```json +{ + "id": "string (ex: 'rabbit')", + "name": "string", + "rarity": "int", + "ideal_biome": "int", + "ideal_temp": "float", + "base_value": "int" +} +``` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Bestiaire / Encyclopédie (Consultation) +**Description UX** : Le joueur consulte les animaux découverts/possédés. +**Description UI** : Livre ou Grille d'icônes. Les animaux non découverts sont des silhouettes noires. +**Emplacement** : Menu Principal -> Bestiaire. +**Intégration** : Modal Plein Écran. +**Navigation** : Filtres (Biome, Rareté) -> Clic Animal -> Détail. +**Événements** : `OPEN_BESTIARY`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `page_turn.mp3`. +- **Graphiques** : Fond Livre ancien. +- **Images** : 75 Icônes Animaux. +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Sépia/Papier. +- **Textes** : Lore/Description. +- **Formes** : N/A diff --git a/docs/specs/inventaire_bebe_animaux.md b/docs/specs/inventaire_bebe_animaux.md new file mode 100644 index 0000000..ad5d567 --- /dev/null +++ b/docs/specs/inventaire_bebe_animaux.md @@ -0,0 +1,104 @@ +# Spécifications : Inventaire des Bébés Animaux + +## Correspondance +Pour chaque animal adulte, il existe une version "Bébé". +- **Visuel** : Version "chibi" ou réduite du sprite adulte. +- **Nom** : "Bébé [Nom Animal]". + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Même table que `animal_types` avec flag `is_baby`. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Visualisation Bébé (Passif) +**Description UX** : Différencier les bébés des adultes. +**Description UI** : Sprite plus petit (50% taille), tête plus grosse, yeux plus grands. +**Emplacement** : Grille / Nurserie. +**Intégration** : Sprite. +**Navigation** : N/A +**Événements** : N/A + +#### Assets +- **Musiques** : N/A +- **Sons** : Cris plus aigus. +- **Graphiques** : Sprites Chibi. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Démarche maladroite. +- **Couleurs** : Identiques Adulte. +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/inventaire_heures.md b/docs/specs/inventaire_heures.md new file mode 100644 index 0000000..c0ff56f --- /dev/null +++ b/docs/specs/inventaire_heures.md @@ -0,0 +1,105 @@ +# Spécifications : Inventaire des Heures de la Journée + +## Cycle Journalier +- **Aube** (06h-09h) : Arrivée des premiers visiteurs. +- **Jour** (09h-18h) : Pic d'activité, chaleur maximale. +- **Crépuscule** (18h-21h) : Départ des visiteurs. +- **Nuit** (21h-06h) : Zoo fermé (sauf événements nocturnes), Température baisse, Repos des animaux. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Table statique. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Cycle perpétuel. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Voir `temperature.md`. + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Aube/Crépuscule** : Changement luminosité globale. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Cycle Jour/Nuit (Passif) +**Description UX** : La luminosité change progressivement. +**Description UI** : Filtre global de couleur (Bleu nuit -> Orange aube -> Blanc jour -> Orange crépuscule). +**Emplacement** : Écran entier. +**Intégration** : Layer FX. +**Navigation** : N/A +**Événements** : `TIME_CHANGE`. + +#### Assets +- **Musiques** : Thème Jour / Thème Nuit (plus calme). +- **Sons** : Coq (Matin), Hibou (Nuit). +- **Graphiques** : Ombres qui tournent (si 3D ou simulé). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Transition couleur. +- **Couleurs** : Palette Cycle. +- **Textes** : Horloge "14:00". +- **Formes** : N/A diff --git a/docs/specs/inventaire_meteos.md b/docs/specs/inventaire_meteos.md new file mode 100644 index 0000000..cf82dc1 --- /dev/null +++ b/docs/specs/inventaire_meteos.md @@ -0,0 +1,109 @@ +# Spécifications : Inventaire des Météos + +## Types de Météo +1. **Ensoleillé** : Bonus attractivité, Température +1°C. +2. **Nuageux** : Neutre. +3. **Pluvieux** : Malus attractivité (visiteurs partent plus vite), Bonus biome "Marécage/Océan". +4. **Orage** : Stress animaux (bruit), Malus fort visiteurs. +5. **Neige** : Température -5°C, Bonus biome "Polaire/Montagne". +6. **Canicule** : Température +5°C, Risque surchauffe. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Table statique. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Probabilités selon Saison. + +### Conditions de Disparition +Changement toutes les X heures ou jours. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Voir liste ci-dessus. + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +Voir liste ci-dessus. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Neige/Orage** : Ralentissement camions (-20%). + +### Dépenses (Boutiques/Visiteurs) +**Pluie** : Augmente vente parapluies/abris. + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Changement Météo** : Notification. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `WEATHER_CHANGE` | Info | "La météo change : [Nouvelle Météo]" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Ambiance Météo (Passif) +**Description UX** : Le joueur ressent la météo actuelle. +**Description UI** : Overlay plein écran (Pluie, Neige, Rayons soleil). Nuages qui passent (ombres). +**Emplacement** : Écran entier. +**Intégration** : Layer FX. +**Navigation** : N/A +**Événements** : `WEATHER_FX`. + +#### Assets +- **Musiques** : N/A +- **Sons** : Pluie, Vent, Tonnerre. +- **Graphiques** : Particules Pluie/Neige. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Chute particules. +- **Couleurs** : Teinte Bleue (Nuit/Pluie), Jaune (Soleil). +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/inventaire_milieux.md b/docs/specs/inventaire_milieux.md new file mode 100644 index 0000000..c64d41f --- /dev/null +++ b/docs/specs/inventaire_milieux.md @@ -0,0 +1,116 @@ +# Spécifications : Inventaire des Milieux + +## Liste des Milieux (Couleurs) +1. **Prairie Tendre** (Vert clair) +2. **Forêt Dense** (Vert foncé) +3. **Savane** (Jaune/Ocre) +4. **Désert** (Sable) +5. **Plage** (Beige) +6. **Océan Surface** (Bleu clair) +7. **Océan Profond** (Bleu foncé) +8. **Récif** (Corail/Multicolore) +9. **Montagne Basse** (Gris vert) +10. **Roche** (Gris) +11. **Sommet Enneigé** (Blanc) +12. **Volcanique** (Noir/Rouge) +13. **Marécage** (Vert boue) +14. **Toundra** (Bleu gris) +15. **Jungle** (Vert saturé) + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Table statique (Reference Data). + +### Caractéristiques Initiales +Voir liste ci-dessus. + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Chaque milieu a une T° par défaut. + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Palette Biome (Consultation) +**Description UX** : Le joueur consulte la liste des biomes disponibles pour terraformer. +**Description UI** : Grille de carrés colorés avec infobulle (Nom, T°). +**Emplacement** : Outil Terraformation. +**Intégration** : Sélecteur. +**Navigation** : Clic Couleur. +**Événements** : `SELECT_BIOME`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `select.mp3`. +- **Graphiques** : Carrés de texture. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Palette 15 couleurs. +- **Textes** : Noms des milieux. +- **Formes** : Carrés. diff --git a/docs/specs/inventaire_problemes_visiteurs.md b/docs/specs/inventaire_problemes_visiteurs.md new file mode 100644 index 0000000..e33f0dc --- /dev/null +++ b/docs/specs/inventaire_problemes_visiteurs.md @@ -0,0 +1,108 @@ +# Spécifications : Inventaire des Problèmes et Exigences Visiteurs + +## Liste des Incidents +1. **Soif** : "J'ai soif !" -> Nécessite un stand de boisson ou une boutique proche. +2. **Fatigue** : "J'ai mal aux pieds..." -> Nécessite un banc. +3. **Saleté** : "C'est sale ici !" -> Nécessite un nettoyage (clic) ou une poubelle. +4. **Frustration** : "On ne voit rien !" -> Animal trop loin ou caché. +5. **Envie** : "Je veux une peluche !" -> Nécessite une boutique de souvenirs. +6. **Photo** : "Quelle merveille !" -> Le visiteur veut prendre une photo (clic bonus). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Table statique. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Aléatoire sur Visiteur. + +### Conditions de Disparition +Résolution ou Départ visiteur. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +**Canicule** : Augmente probabilité "Soif". + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Incident Apparu** : Bulle visible. +* **Incident Résolu** : Gain. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Bulles d'Incidents (Action) +**Description UX** : Voir `visiteur.md`. +**Description UI** : Icônes spécifiques pour chaque problème (Goutte, Zzz, Poubelle, Œil barré, Cadeau, Appareil photo). +**Emplacement** : Visiteur. +**Intégration** : Bulle. +**Navigation** : Clic. +**Événements** : `INCIDENT_ICON`. + +#### Assets +- **Musiques** : N/A +- **Sons** : Variés selon incident (Bruit eau, Bâillement). +- **Graphiques** : Set d'icônes vectorielles. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Pop. +- **Couleurs** : Noir sur Blanc. +- **Textes** : N/A +- **Formes** : Bulle. diff --git a/docs/specs/inventaire_quetes.md b/docs/specs/inventaire_quetes.md new file mode 100644 index 0000000..956f56f --- /dev/null +++ b/docs/specs/inventaire_quetes.md @@ -0,0 +1,116 @@ +# Spécifications : Inventaire des Quêtes + +## Types d'Objectifs (Journaliers) +1. **Économique** : "Gagner 1000 pièces", "Vendre pour 500 pièces". +2. **Construction** : "Construire une boutique", "Améliorer la nurserie". +3. **Élevage** : "Faire naître un bébé", "Avoir 3 animaux de milieu Océan". +4. **Social** : "Avoir 50 visiteurs simultanés", "Résoudre 5 incidents". + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "quest", + "objective_type": "string", + "target_value": "int", + "reward_coins": "int", + "is_completed": "boolean" +} +``` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Reset quotidien (Minuit serveur). + +### Conditions de Disparition +Complétion ou Reset. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Quête Complétée** : Gain récompense. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `QUEST_COMPLETE` | Succès | "Quête terminée ! Récompense reçue." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Suivi Quêtes (Permanent) +**Description UX** : Le joueur voit ses objectifs en cours. +**Description UI** : Liste compacte dans le HUD (ex: "Naissances : 2/5"). Coche verte quand fini. +**Emplacement** : HUD Droite. +**Intégration** : Widget rétractable. +**Navigation** : Clic -> Détail Quêtes. +**Événements** : `QUEST_UPDATE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `quest_complete.mp3`. +- **Graphiques** : Icône Parchemin. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Flash vert à la complétion. +- **Couleurs** : Blanc (En cours), Vert (Fini). +- **Textes** : Titre Quête, Progression. +- **Formes** : Liste. diff --git a/docs/specs/inventaire_saisons.md b/docs/specs/inventaire_saisons.md new file mode 100644 index 0000000..f2a996d --- /dev/null +++ b/docs/specs/inventaire_saisons.md @@ -0,0 +1,146 @@ +# Spécifications : Inventaire des Saisons + +## Cycle Annuel +Le jeu intègre un cycle de 4 saisons qui influence l'environnement et les animaux. +Chaque saison dure un nombre défini de jours de jeu (ex: 7 jours réels ou 30 jours in-game). + +## Liste des Saisons + +### 1. Printemps +- **Météo dominante** : Ensoleillé, Pluvieux. +- **Température** : Tempérée (+0°C). +- **Impact Général** : + - **Reproduction** : Bonus fort pour la plupart des animaux (+20% naissances). + - **Visiteurs** : Affluence moyenne. +- **Impact Spécifique** : + - Favorise les animaux de type "Prairie" et "Forêt". + +### 2. Été +- **Météo dominante** : Ensoleillé, Canicule, Orage. +- **Température** : Chaude (+5°C à +10°C). +- **Impact Général** : + - **Survie** : Risque de surchauffe pour les animaux polaires/montagne. + - **Visiteurs** : Pic d'affluence (vacances), forte demande en boissons/glaces. +- **Impact Spécifique** : + - Favorise les animaux de type "Désert", "Savane", "Jungle". + - Pénalise les animaux de type "Toundra", "Polaire". + +### 3. Automne +- **Météo dominante** : Pluvieux, Nuageux, Vent. +- **Température** : Fraîche (-2°C). +- **Impact Général** : + - **Reproduction** : Taux normal. + - **Maladie** : Légère augmentation des risques si les animaux sont mouillés/froids. +- **Impact Spécifique** : + - Favorise les animaux de type "Forêt", "Marécage". + +### 4. Hiver +- **Météo dominante** : Neige, Nuageux, Froid. +- **Température** : Froide (-5°C à -15°C). +- **Impact Général** : + - **Survie** : Risque d'hypothermie pour les animaux tropicaux. + - **Reproduction** : Malus fort (sauf espèces adaptées). + - **Visiteurs** : Baisse d'affluence, sauf si événements spéciaux (Noël). +- **Impact Spécifique** : + - Favorise les animaux de type "Toundra", "Polaire", "Montagne". + - Pénalise fortement les animaux de type "Désert", "Jungle", "Récif". + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Table statique. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Cycle annuel fixe. + +### Conditions de Disparition +Fin de durée saison. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Voir liste ci-dessus. + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +Voir liste ci-dessus. + +### Impact Mort +Voir liste ci-dessus. + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +Voir liste ci-dessus. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Changement Saison** : Notification majeure. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `SEASON_CHANGE` | Info | "C'est le [Saison] !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Ambiance Saisonnière (Passif) +**Description UX** : Le décor change selon la saison. +**Description UI** : Tilesets modifiés (Herbe verte -> Herbe jaune -> Feuilles mortes -> Neige). +**Emplacement** : Grille Zoo. +**Intégration** : Assets graphiques. +**Navigation** : N/A +**Événements** : `SEASON_VISUAL_UPDATE`. + +#### Assets +- **Musiques** : Thèmes saisonniers (Vivaldi remix ?). +- **Sons** : Vent hivernal, Oiseaux printemps. +- **Graphiques** : 4 variantes de chaque Tile. +- **Images** : Icône Saison HUD. +- **Vidéos** : N/A +- **Animations** : Feuilles qui tombent (Automne). +- **Couleurs** : Vert, Jaune, Orange, Blanc. +- **Textes** : "Printemps", "Été", etc. +- **Formes** : N/A diff --git a/docs/specs/inventaire_temperatures.md b/docs/specs/inventaire_temperatures.md new file mode 100644 index 0000000..a8c71bb --- /dev/null +++ b/docs/specs/inventaire_temperatures.md @@ -0,0 +1,108 @@ +# Spécifications : Inventaire des Températures + +## Échelle de Température +1. **Polaire** (-30°C à -10°C) - Feedback : Givre intense +2. **Froid** (-10°C à 5°C) - Feedback : Givre léger +3. **Frais** (5°C à 15°C) +4. **Tempéré** (15°C à 25°C) - Idéal pour la plupart des animaux de base +5. **Chaud** (25°C à 35°C) +6. **Aride** (35°C à 50°C) - Feedback : Vapeur légère +7. **Brûlant** (> 50°C) - Feedback : Vapeur rouge/Feu + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Table statique. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Thermomètre (Consultation) +**Description UX** : Le joueur voit la température actuelle. +**Description UI** : Jauge verticale (Thermomètre) avec mercure rouge. Graduations colorées (Bleu bas, Rouge haut). +**Emplacement** : HUD ou Info Case. +**Intégration** : Widget. +**Navigation** : N/A +**Événements** : `UPDATE_TEMP`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Sprite Thermomètre. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Niveau qui monte/descend. +- **Couleurs** : Dégradé Bleu/Rouge. +- **Textes** : "20°C". +- **Formes** : N/A diff --git a/docs/specs/joueur.md b/docs/specs/joueur.md new file mode 100644 index 0000000..afe5b83 --- /dev/null +++ b/docs/specs/joueur.md @@ -0,0 +1,158 @@ +# Spécifications : Joueur + +## Définition +L'utilisateur humain. + +## Propriétés +- **Compte** : Authentifié par clé privée (pas de mot de passe). +- **Ressources** : Pièces, Unités de recherche. +- **Zoo** : Possède et gère un unique zoo. +- **Démarrage** : Commence avec 200 pièces et 3 couples reproducteurs basiques. + +## Actions +- Achat/Vente. +- Placement/Déplacement. +- Upgrade bâtiments/carte. +- Gestion des incidents visiteurs. +- Configuration du profil d'absence (Bot). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "public_key": "string (ed25519)", + "username": "string", + "created_at": "timestamp", + "last_login": "timestamp", + "resources": { + "coins": "integer", + "research_points": "integer" + }, + "settings": { + "music_enabled": "boolean", + "bot_profile_id": "integer" + } +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Pièces | 200 | +| Recherche | 0 | +| Animaux | 3 couples (6 animaux) | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Inscription. + +### Conditions de Disparition +Suppression compte. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +**Démarrage autonome** : Le joueur reçoit 3 couples reproducteurs basiques (Lapins/Poules/Canards selon biome) pour garantir une reproduction immédiate et alimenter le marché bas prix. + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Connexion** : Chargement état. +* **Quête** : Validation. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `WELCOME` | Info | "Bienvenue dans votre nouveau zoo !" | +| `NO_MONEY` | Alerte | "Fonds insuffisants." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### HUD Principal (Permanent) +**Description UX** : Le joueur voit en permanence ses ressources et accès rapides. +**Description UI** : Barre supérieure avec Compteurs (Pièces, Recherche, Réputation). Barre inférieure ou latérale avec Menus (Construction, Carte Monde, Inventaire, Profil). +**Emplacement** : Écran (Overlay). +**Intégration** : Fixe. +**Navigation** : Clic Menu -> Ouverture Panneau. +**Événements** : `UI_CLICK`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `ui_click.mp3`. +- **Graphiques** : Style UI "Bois et Pierre" ou "Moderne Clean". +- **Images** : Icônes (Pièce, Fiole, Marteau, Carte). +- **Vidéos** : N/A +- **Animations** : Compteurs qui défilent quand gain/perte. +- **Couleurs** : Or, Bleu, Blanc. +- **Textes** : Chiffres, Labels menus. +- **Formes** : Barres, Boutons ronds. + +### Profil & Bot (Configuration) +**Description UX** : Le joueur configure son avatar et son bot d'absence. +**Description UI** : Modal Profil. Onglet "Gestion Absence" avec sélecteur de Bot (Famille > Spécialisation). +**Emplacement** : Menu Profil. +**Intégration** : Modal. +**Navigation** : Profil -> Bot -> Sauvegarder. +**Événements** : `SAVE_PROFILE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `save_success.mp3`. +- **Graphiques** : Portraits de bots. +- **Images** : Avatars joueurs. +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : N/A +- **Textes** : Description des bots ("Le Conservateur : Achète de la nourriture..."). +- **Formes** : Cartes de sélection. diff --git a/docs/specs/laboratoire.md b/docs/specs/laboratoire.md new file mode 100644 index 0000000..a39ed7d --- /dev/null +++ b/docs/specs/laboratoire.md @@ -0,0 +1,118 @@ +# Spécifications : Laboratoire (Site) + +## Définition +Un laboratoire est un point d'intérêt spécial sur la Carte du Monde (POI). + +## Fonction +- **Offres Spéciales** : Propose périodiquement des œufs ou bébés animaux rares. +- **Prix Fixe** : Contrairement aux zoos, les ventes du laboratoire sont souvent à prix fixe, sans enchères (ou règles spécifiques). +- **Icône** : 🔬 + +## Interaction +- **Achat** : Glisser l'offre du laboratoire vers le camion du joueur. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "lab", + "current_offer": { "animal_type": "string", "rarity": "int", "price": "int", "expires_at": "timestamp" } +} +``` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Génération carte. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Rotation Offres** : 1 offre toutes les 4h à 24h. + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Nouvelle Offre Rare** : Notification globale ou locale. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `LAB_OFFER` | Info | "Le laboratoire propose un spécimen rare !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Offre Rare (Passif) +**Description UX** : Le laboratoire a un stock spécial. +**Description UI** : Halo brillant autour du labo. Icône "!" ou "ADN". +**Emplacement** : Carte Monde. +**Intégration** : Effet visuel. +**Navigation** : N/A +**Événements** : `LAB_GLOW`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `sci_fi_hum.mp3`. +- **Graphiques** : Halo bleu néon. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Pulsation. +- **Couleurs** : Bleu électrique / Violet. +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/milieu.md b/docs/specs/milieu.md new file mode 100644 index 0000000..bd1ac08 --- /dev/null +++ b/docs/specs/milieu.md @@ -0,0 +1,153 @@ +# Spécifications : Milieu (Biome/Couleur) + +## Définition +Le milieu représente l'environnement écologique d'une case, défini par sa couleur. + +## Types de Milieux +- **Prairie** (Tiers Gauche) +- **Océan** (Tiers Milieu) +- **Montagne** (Tiers Droite) +- *Note : D'autres milieux (Forêt, Désert...) peuvent être définis par des couleurs intermédiaires.* + +## Impact +- **Survie** : Chaque animal a un milieu de prédilection. Un écart trop important entre la couleur de la case et la couleur idéale de l'animal entraîne stress, maladie et mort. +- **Reproduction** : Un milieu idéal favorise la reproduction (bonus de score). +- **Visuel** : Le fond de la case affiche la couleur du milieu. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "integer (0-14)", + "name": "string", + "color_hex": "string (#RRGGBB)", + "default_temperature": "float", + "fertility_bonus": "float" +} +``` + +### Caractéristiques Initiales +| ID | Nom | Couleur | T° Base | +| :--- | :--- | :--- | :--- | +| 0 | Prairie Tendre | #90EE90 | 20°C | +| 5 | Océan Surface | #00BFFF | 15°C | +| 9 | Montagne Basse | #A9A9A9 | 5°C | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Génération carte (fixe). + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Le milieu définit la température de base de la case (avant modificateurs saison/upgrade). + +### Impact Milieu (Biome) +Référence pour la compatibilité animale. + +### Impact Saisons +| Milieu | Sensibilité Saisons | +| :--- | :--- | +| Océan | Faible (Inertie thermique) | +| Montagne | Forte (Neige en hiver) | +| Prairie | Moyenne | + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +**Bonus** : +10% fertilité si animal sur son biome exact. + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +**Esthétique** : Un zoo avec des biomes variés est plus attractif (+5% par biome différent utilisé). + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +**Changement de Milieu** : +| Niveau | Coût | Précision | Temps | +| :--- | :--- | :--- | :--- | +| 1 | 500 | Changement vers biome adjacent | 0s | +| 7 | 5000 | Changement vers n'importe quel biome | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Identification Visuelle (Passif) +**Description UX** : Le joueur identifie le biome à la couleur du sol. +**Description UI** : Textures distinctes (Herbe, Sable, Roche, Eau). Transitions douces (dégradés) entre les cases adjacentes. +**Textures** : Motifs discrets (brins d'herbe, cailloux, craquelures) superposés à la couleur pour l'accessibilité (daltonisme). +**Emplacement** : Fond de Case. +**Intégration** : Layer 0 (Background). +**Navigation** : N/A +**Événements** : `MAP_RENDER`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Tilesets (15 variations de couleur/texture). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Eau qui ondule (Océan), Herbe qui bouge (Prairie). +- **Couleurs** : Palette 15 couleurs (Cercle chromatique). +- **Textes** : Tooltip "Biome : Prairie". +- **Formes** : Carré. + +### Feedback Incompatibilité (Passif) +**Description UX** : Un animal est sur le mauvais biome. +**Description UI** : L'herbe sous l'animal jaunit ou devient boueuse. L'animal a une bulle "Beurk". +**Emplacement** : Case Zoo. +**Intégration** : Overlay Case. +**Navigation** : N/A +**Événements** : `BIOME_MISMATCH`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Overlay "Sol Mort". +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Transition lente vers le jaune/marron. +- **Couleurs** : Jaune paille, Marron. +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/mort_bebe.md b/docs/specs/mort_bebe.md new file mode 100644 index 0000000..7cce7c1 --- /dev/null +++ b/docs/specs/mort_bebe.md @@ -0,0 +1,130 @@ +# Spécifications : Mort des Bébés + +## Causes Spécifiques +- **Nurserie Pleine** : Si un œuf éclot et qu'il n'y a pas de place en nurserie ni sur le terrain. +- **Vente Échouée** : Bébé resté trop longtemps en zone de vente (camion) sans acheteur et sans rapatriement. +- **Conditions Extrêmes** : Les bébés ont des tolérances de température plus faibles que les adultes. + +## Conséquences +- Perte définitive de l'animal. +- Pénalité forte sur le Score de Réputation. +- Pas de récupération de ressources (pièces). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Événement loggé. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Voir Causes. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Voir `bebe_animal.md`. + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +**Malus** : -50 points de Réputation (temporaire 24h). + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Mort Bébé** : Notification critique. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `BABY_DIED` | Critique | "Un bébé est mort ! Les visiteurs sont choqués." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Drame (Événement) +**Description UX** : Le bébé meurt. +**Description UI** : Le sprite devient gris/fantôme puis disparaît (montée au ciel). Musique triste. +**Emplacement** : Case Zoo/Nurserie. +**Intégration** : Immédiat. +**Navigation** : N/A +**Événements** : `DEATH_ANIMATION`. + +#### Assets +- **Musiques** : Jingle Triste (Violon). +- **Sons** : `gong.mp3` ou vent lugubre. +- **Graphiques** : Fantôme. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Opacité 100% -> 0%. Translation Y vers le haut. +- **Couleurs** : Gris, Noir. +- **Textes** : "RIP". +- **Formes** : N/A + +### Notification (Alerte) +**Description UX** : Le joueur est informé de la perte. +**Description UI** : Popup Rouge/Noir. "Vous avez perdu un bébé [Cause]". +**Emplacement** : Centre Écran. +**Intégration** : Modal bloquante (Acknowledgment requis). +**Navigation** : Clic OK. +**Événements** : `DEATH_ACKNOWLEDGE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Crâne (icône). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Noir #000000. +- **Textes** : Cause du décès. +- **Formes** : N/A diff --git a/docs/specs/nurserie.md b/docs/specs/nurserie.md new file mode 100644 index 0000000..9540df1 --- /dev/null +++ b/docs/specs/nurserie.md @@ -0,0 +1,215 @@ +# Spécifications : Nurserie + +## Définition +Bâtiment dédié à l'éclosion des œufs et à la croissance des bébés animaux. + +## Fonctionnalités +- **Éclosion** : Lieu où les œufs achetés sont placés pour éclore. +- **Croissance** : Permet aux bébés de grandir en sécurité. +- **Stockage** : Zone tampon avant placement dans le zoo. + +## Niveaux d'Amélioration (7 niveaux) +- **Coût** : Progressif par palier. +- **Effet** : Accélère le temps d'éclosion/croissance. Améliore la qualité des reproducteurs (bonus génétique). +- **Ratio** : 1 unité permet de faire grandir 1 bébé maximum à la fois. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "nurserie", + "level": "integer (1-7)", + "slots": [ + { "slot_id": 1, "content": "egg_uuid | baby_uuid | null", "timer_end": "timestamp" } + ] +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale (Niv 1) | +| :--- | :--- | +| Slots | 1 | +| Vitesse Éclosion | x1.0 | +| Protection T° | +/- 2°C tolérance | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Construction par joueur ou présent au début. + +### Conditions de Disparition +Destruction par joueur (si vide). + +### Hérédité +**Bonus Génétique** : Niveau 5+ donne +5% chance rareté supérieure à l'éclosion. + +## 3. Impacts Environnementaux +### Impact Température +La nurserie régule la température interne. +| Niveau | Régulation | +| :--- | :--- | +| 1 | +/- 2°C | +| 7 | +/- 10°C (Climatisation parfaite) | + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +Protège des effets négatifs des saisons (Hiver/Été). + +### Impact Heure / Jour-Nuit +Fonctionne 24h/24. + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A (Lieu de résultat de la reproduction). + +### Impact Mort +Réduit drastiquement la mortalité infantile (Santé ne baisse pas sauf si Faim extrême). + +### Impact Nourriture +Les bébés sont nourris automatiquement si stock nourriture global > 0. + +### Impact Attractivité (Visiteurs/Animaux) +N/A (Les visiteurs ne voient pas l'intérieur). + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +**Vitesse Éclosion/Croissance** : +| Niveau | Multiplicateur Vitesse | +| :--- | :--- | +| 1 | x1.0 | +| 2 | x1.2 | +| 3 | x1.5 | +| 4 | x2.0 | +| 5 | x3.0 | +| 6 | x5.0 | +| 7 | x10.0 | + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Éclosion** : Œuf -> Bébé. +* **Maturité** : Bébé -> Adulte (prêt à sortir). + +## 7. Progression +### Tableau des Upgrades +| Niveau | Coût (Pièces) | Slots | Vitesse | Temps Construction | +| :--- | :--- | :--- | :--- | :--- | +| 1 | 100 | 1 | x1.0 | 0s | +| 2 | 200 | 2 | x1.2 | 0s | +| 3 | 400 | 3 | x1.5 | 0s | +| 4 | 800 | 4 | x2.0 | 0s | +| 5 | 1600 | 5 | x3.0 | 0s | +| 6 | 3200 | 6 | x5.0 | 0s | +| 7 | 6400 | 7 | x10.0 | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function updateNurserie(nurserie): + for slot in nurserie.slots: + if slot.content is Egg: + slot.timer -= baseTime * nurserie.speedMultiplier + if slot.timer <= 0: + hatchEgg(slot.content) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `EGG_HATCHED` | Info | "Un œuf a éclos en Nurserie !" | +| `NURSERIE_FULL` | Warning | "Nurserie pleine, impossible d'acheter/reproduire." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Gestion des Slots (Consultation) +**Description UX** : Le joueur clique sur la nurserie pour voir l'état de ses œufs et bébés. +**Description UI** : **Bottom Sheet** avec une grille de slots (1 à 7 selon niveau). Chaque slot affiche l'icône de l'œuf/bébé, une barre de progression (éclosion/croissance) et un timer restant. +**Emplacement** : Case Nurserie. +**Intégration** : Modal centrée. +**Navigation** : Clic Nurserie -> Modal -> Clic Slot (Action) ou Fermer. +**Événements** : `OPEN_NURSERIE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `ui_open.mp3` +- **Graphiques** : Style "Couveuse" ou "Nid douillet". +- **Images** : `egg_[rarity].png`, `baby_[species].png`. +- **Vidéos** : N/A +- **Animations** : Œuf qui tremble légèrement quand proche éclosion. +- **Couleurs** : Tons pastels (rose, bleu clair, jaune paille). +- **Textes** : "Slot X", "Temps restant : MM:SS". +- **Formes** : Cases de grille arrondies. + +### Placement d'un Œuf +**Description UX** : Après achat ou reproduction, l'œuf va automatiquement dans un slot vide. Si plein, notification d'erreur. +**Description UI** : Animation de l'œuf "volant" vers la nurserie (si visible) ou simple apparition dans le slot. +**Emplacement** : HUD -> Nurserie. +**Intégration** : Automatique. +**Navigation** : N/A +**Événements** : `EGG_ADDED`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `pop_soft.mp3` +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Fade in + Scale up de l'œuf dans le slot. +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A + +### Éclosion (Action) +**Description UX** : Quand le timer est fini, l'œuf se fissure. Le joueur clique pour "ouvrir" l'œuf et découvrir le bébé. +**Description UI** : Le slot brille ou clignote. Au clic, animation d'éclosion et popup "Nouveau Bébé !" avec ses stats. +**Emplacement** : Modal Nurserie. +**Intégration** : Bloque le slot jusqu'à validation. +**Navigation** : Clic Œuf éclos -> Popup Récompense -> Retour Modal. +**Événements** : `HATCH_CLICK`. + +#### Assets +- **Musiques** : Jingle court "Victoire/Naissance". +- **Sons** : `crack_egg.mp3`, `baby_cry.mp3`. +- **Graphiques** : Coquilles brisées. +- **Images** : Sprite bébé. +- **Vidéos** : N/A +- **Animations** : Séquence d'éclosion (3 frames). +- **Couleurs** : Rayons dorés/blancs. +- **Textes** : "C'est un [Espèce] [Rareté] !". +- **Formes** : Étoile ou Cercle rayonnant. + +### Sortie de Nurserie (Placement) +**Description UX** : Le bébé est mature (ou le joueur veut le sortir). Drag & drop du slot vers une case du zoo. +**Description UI** : Le curseur devient le sprite du bébé/animal. Les cases valides s'illuminent en vert, invalides en rouge. +**Alternative** : Sélectionner le bébé -> Cliquer sur la case cible (Tap to Place). +**Emplacement** : Modal Nurserie -> Carte Zoo. +**Intégration** : Ferme la modal et passe en mode placement. +**Navigation** : Drag Slot -> Drop Carte. +**Événements** : `DRAG_START`, `DRAG_DROP`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `pickup.mp3`, `place_ok.mp3` / `place_error.mp3`. +- **Graphiques** : Grille de placement overlay. +- **Images** : Sprite fantôme (translucide) sous le curseur. +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Vert #00FF00 (OK), Rouge #FF0000 (KO). +- **Textes** : Tooltip "Placer ici". +- **Formes** : N/A diff --git a/docs/specs/personnage_generique.md b/docs/specs/personnage_generique.md new file mode 100644 index 0000000..6eb12ff --- /dev/null +++ b/docs/specs/personnage_generique.md @@ -0,0 +1,116 @@ +# Spécifications : Personnage (Générique) + +## Définition +Entité active dans le jeu, qu'elle soit contrôlée par un humain, une IA ou un script. + +## Types +- **Joueur** : Utilisateur humain gérant son zoo. +- **Bot** : IA gérant un zoo concurrent. +- **Visiteur** : PNJ se promenant dans le zoo. +- **Animal** : Entité biologique gérée par le joueur. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Classe abstraite ou interface commune. +```json +{ + "id": "uuid", + "type": "string", + "position": { "x": "int", "y": "int" }, + "active": "boolean" +} +``` + +### Caractéristiques Initiales +N/A (Spécifique aux sous-types) + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Identification (Passif) +**Description UX** : Reconnaître le type de personnage au premier coup d'œil. +**Description UI** : Silhouette distincte, Code couleur, Badge/Nom au survol. +**Emplacement** : Grille. +**Intégration** : Tooltip. +**Navigation** : Survol souris / Tap. +**Événements** : `HOVER_CHARACTER`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Silhouettes archétypales. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Joueur (Bleu), Bot (Rouge/Gris), Visiteur (Multicolore), Animal (Naturel). +- **Textes** : Nom, Rôle. +- **Formes** : N/A diff --git a/docs/specs/reproduction.md b/docs/specs/reproduction.md new file mode 100644 index 0000000..e536c60 --- /dev/null +++ b/docs/specs/reproduction.md @@ -0,0 +1,145 @@ +# Spécifications : Reproduction + +## Conditions +- **Proximité** : Deux animaux compatibles (même espèce, sexe opposé si géré, ou juste compatibilité) proches l'un de l'autre. +- **Environnement** : Température et Biome idéaux. +- **Nourriture** : Animaux bien nourris. +- **Score de Reproduction** : Le zoo doit avoir un score suffisant pour favoriser les naissances. + +## Résultat +- **Naissance** : Apparition d'un nouveau bébé. +- **Destination** : Va directement en Nurserie si place disponible, sinon doit être placé ou vendu immédiatement. +- **Hérédité** : Le bébé hérite des caractéristiques des parents avec une part de mutation (Rareté/Couleur). + +## Bonus +- Les naissances augmentent l'attractivité du zoo. +- Les naissances favorisent l'apparition d'autres naissances (cercle vertueux). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Logique serveur. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +| Parent A | Parent B | Enfant (Probabilités) | +| :--- | :--- | :--- | +| Rareté N | Rareté N | N (80%), N+1 (15%), N-1 (5%) | +| Rareté N | Rareté N+1 | N (40%), N+1 (40%), N+2 (10%), N-1 (10%) | + +## 3. Impacts Environnementaux +### Impact Température +**Condition** : T° doit être dans la plage "Idéale" (+/- 2°C). + +### Impact Milieu (Biome) +**Condition** : Biome doit être "Parfait". + +### Impact Saisons +**Printemps** : +20% chance réussite. +**Hiver** : -50% chance réussite (sauf animaux froids). + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +**Condition** : Faim < 20%. + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Accouplement** : Tentative. +* **Grossesse** : Délai avant naissance. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +if checkConditions(parentA, parentB): + chance = baseChance + seasonBonus + zooReproScoreBonus + if random() < chance: + createBaby(parentA, parentB) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `REPRO_SUCCESS` | Succès | "Un couple attend un heureux événement !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Amour (Passif) +**Description UX** : Deux animaux compatibles se rencontrent. Des cœurs apparaissent. +**Description UI** : Particules Cœurs au-dessus des animaux. Ils se rapprochent. +**Emplacement** : Case Zoo. +**Intégration** : Animation comportementale. +**Navigation** : N/A +**Événements** : `MATING_START`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `kiss.mp3` ou bruit doux. +- **Graphiques** : Cœurs roses. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Cœurs qui montent. +- **Couleurs** : Rose. +- **Textes** : N/A +- **Formes** : Cœur. + +### Naissance (Événement) +**Description UX** : Un bébé apparaît (ou un œuf en nurserie). Notification joyeuse. +**Description UI** : Popup "Carnet Rose". +**Emplacement** : HUD. +**Intégration** : Notification Toast. +**Navigation** : Clic Notification -> Voir Bébé. +**Événements** : `BIRTH`. + +#### Assets +- **Musiques** : Jingle Naissance. +- **Sons** : `tada.mp3`. +- **Graphiques** : Cigogne (icône). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Confettis. +- **Couleurs** : Rose/Bleu. +- **Textes** : "Bienvenue au nouveau [Espèce] !". +- **Formes** : N/A diff --git a/docs/specs/score_reputation.md b/docs/specs/score_reputation.md new file mode 100644 index 0000000..ad9ef30 --- /dev/null +++ b/docs/specs/score_reputation.md @@ -0,0 +1,117 @@ +# Spécifications : Score de Réputation + +## Définition +Indicateur global de la qualité et du prestige du zoo. C'est le moteur de l'économie (Visiteurs). + +## Composantes +- **Valeur du Cheptel** : Somme des valeurs de rareté des animaux vivants. +- **Diversité** : Bonus pour chaque espèce différente présente. +- **Bien-être** : Bonus si le Score de Survie moyen est élevé. +- **Esthétique/Aménagement** : Bonus pour les bâtiments améliorés. + +## Malus +- **Morts** : Chaque mort inflige une pénalité temporaire mais significative. +- **Insatisfaction** : Visiteurs repartant mécontents (incidents non résolus). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Propriété de `Zoo`. +`reputation_score: integer` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Réputation | 0 | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +**Bonus** : +10 pts par naissance. + +### Impact Mort +**Malus** : -50 pts par mort (décroissance sur 24h). + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +C'est la métrique de base de l'attractivité. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Passage Niveau** : Notification. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +score = sum(animal.rarity * 10) + (uniqueSpecies * 50) + buildingsValue - deathPenalty +``` + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Affichage Score (Permanent) +**Description UX** : Le joueur connaît son niveau de prestige. +**Description UI** : Étoiles ou Couronne dans le HUD avec barre de progression vers le niveau suivant. +**Emplacement** : HUD Haut. +**Intégration** : Fixe. +**Navigation** : Survol -> Détail calcul. +**Événements** : `SCORE_UPDATE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `level_up_jingle.mp3`. +- **Graphiques** : Icône Couronne. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Brillance quand augmente. +- **Couleurs** : Or. +- **Textes** : "Niveau 5 (4500 pts)". +- **Formes** : Étoile. diff --git a/docs/specs/score_survie.md b/docs/specs/score_survie.md new file mode 100644 index 0000000..142d293 --- /dev/null +++ b/docs/specs/score_survie.md @@ -0,0 +1,118 @@ +# Spécifications : Score de Survie + +## Définition +Indicateur de la santé et de l'adaptation des animaux dans le zoo. + +## Calcul +Moyenne des états de santé individuels de tous les animaux. +`Santé_Animal = f(Faim, Adéquation_Température, Adéquation_Biome, Stress)` + +## Impact +- **Reproduction** : Un score de survie élevé est pré-requis pour la reproduction. +- **Maladie** : Un score faible déclenche l'apparition de maladies et augmente la mortalité. +- **Vente** : Influe sur la valeur perçue des animaux (un animal en bonne santé vaut plus cher). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Propriété de `Zoo`. +`survival_score: float (0-100)` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Survie | 100% | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +Moyenne des adéquations T°. + +### Impact Milieu (Biome) +Moyenne des adéquations Biome. + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +Seuil mini : 80% pour reproduire. + +### Impact Mort +Seuil critique : < 20% (Risque épidémie). + +### Impact Nourriture +Moyenne des Faims. + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +survivalScore = average(allAnimals.health) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `SURVIVAL_LOW` | Alerte | "Attention ! Le score de survie est dangereusement bas." | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Monitoring Santé (Permanent) +**Description UX** : Le joueur surveille la santé globale de son zoo. +**Description UI** : Jauge Cœur ou Croix Verte. Rouge si critique. +**Emplacement** : HUD Haut. +**Intégration** : Fixe. +**Navigation** : Survol -> Liste animaux en danger. +**Événements** : `HEALTH_UPDATE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `heartbeat.mp3` (si critique). +- **Graphiques** : Icône Cœur. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Battement (Pulsation). +- **Couleurs** : Vert -> Jaune -> Rouge. +- **Textes** : "98%". +- **Formes** : Cœur. diff --git a/docs/specs/site_generique.md b/docs/specs/site_generique.md new file mode 100644 index 0000000..6a52090 --- /dev/null +++ b/docs/specs/site_generique.md @@ -0,0 +1,119 @@ +# Spécifications : Site (Générique) + +## Définition +Un site est un lieu d'intérêt positionné sur la Carte du Monde. + +## Types de sites +1. **Zoo** : Espace de gestion d'un joueur ou d'un bot. +2. **Ville** : Zone résidentielle générant des visiteurs. +3. **Laboratoire** : Lieu de production d'animaux spéciaux/rares. + +## Propriétés Communes +- **Position** : Coordonnées fixes (x, y) sur la grille du monde. +- **Nom** : Identifiant unique ou généré. +- **Représentation** : Icône spécifique + Informations contextuelles (Offres, stats). + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "enum", + "name": "string", + "position": { "x": "int", "y": "int" } +} +``` + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Génération procédurale ou création joueur. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Info-bulle (Consultation) +**Description UX** : Survoler un site donne ses détails. +**Description UI** : Tooltip avec Nom, Type, et Info clé (ex: Population pour Ville, Offre pour Zoo). +**Emplacement** : Carte Monde. +**Intégration** : Tooltip souris / Tap hold mobile. +**Navigation** : Survol. +**Événements** : `HOVER_SITE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `pop.mp3` léger. +- **Graphiques** : Fond noir semi-transparent. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Fade in rapide. +- **Couleurs** : Blanc sur Noir. +- **Textes** : "Paris (1M hab.)". +- **Formes** : Rectangle arrondi. diff --git a/docs/specs/tech_architecture.md b/docs/specs/tech_architecture.md new file mode 100644 index 0000000..7d55128 --- /dev/null +++ b/docs/specs/tech_architecture.md @@ -0,0 +1,134 @@ +# Spécifications Techniques : Architecture et Performance + +## 1. Stack Technologique Recommandée + +### Backend +- **Langage** : Node.js (TypeScript) ou Go (pour la performance brute des workers). +- **Framework** : NestJS (Node) ou Gin (Go). +- **Base de Données** : PostgreSQL. + - Utilisation intensive du type `JSONB` pour les données flexibles (états animaux, génétique). + - Indexation GIN sur les champs JSONB souvent requêtés. + +### Frontend +- **Framework** : React ou Vue.js. +- **État** : Zustand ou Pinia (léger et performant). +- **Rendu Grille** : Canvas API (via PixiJS ou Konva) si > 100 éléments animés, sinon DOM optimisé (CSS Grid + Transforms). + +### Infrastructure +- **Cache** : Redis (pour les sessions, les compteurs temps réel et les files d'attente de jobs). +- **Message Broker** : BullMQ (Redis) ou RabbitMQ pour les tâches asynchrones (morts, naissances, enchères). + +--- + +## 2. Modélisation des Données (Hybride Relationnel / Document) + +L'approche hybride permet de garder l'intégrité référentielle sur les entités (Zoo, User) tout en gardant la souplesse sur les attributs de gameplay. + +### Schéma SQL Simplifié + +```sql +CREATE TABLE zoos ( + id UUID PRIMARY KEY, + owner_id UUID NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + resources JSONB DEFAULT '{"coins": 200, "research": 0}', + stats JSONB DEFAULT '{"reputation": 0, "survival": 100}', + last_update TIMESTAMPTZ DEFAULT NOW() -- Clé pour le Lazy Update +); + +CREATE TABLE animals ( + id UUID PRIMARY KEY, + zoo_id UUID REFERENCES zoos(id), + type VARCHAR(50), + birth_date TIMESTAMPTZ, + state JSONB, -- { health, hunger, stress, ... } + genetics JSONB, -- { color, rarity, parents... } + position JSONB -- { x, y } +); +``` + +--- + +## 3. Stratégie de Performance : "Lazy Updates" + +Le calcul temps réel pour 1000 joueurs * 50 animaux est impossible à scaler naïvement. + +### Principe +Ne jamais mettre à jour la BDD à chaque seconde ("tick"). +Calculer l'état d'un zoo **uniquement quand c'est nécessaire** (lecture par le joueur ou interaction). + +### Algorithme de Mise à Jour Différée (Lazy Update) + +1. **État Stocké** : Le zoo est sauvegardé à `T0` avec `Faim = 10`. +2. **Requête** : Le joueur se connecte à `T1` (2 heures plus tard). +3. **Calcul à la Volée** : + * `Delta_Temps = T1 - T0`. + * `Faim_Actuelle = Faim_Initiale + (Vitesse_Faim * Delta_Temps)`. + * Appliquer les seuils (si Faim > 100, déclencher Mort). +4. **Persistance** : Sauvegarder le nouvel état à `T1`. +5. **Réponse** : Envoyer l'état à jour au client. + +### Exceptions (Workers) +Certains événements doivent arriver même si le joueur est hors ligne (ex: Enchères, Morts impactant le marché). +* Utiliser des **Cron Jobs** ou des **Delayed Jobs** (Redis) pour traiter ces événements spécifiques à leur heure d'échéance prévue. + +--- + +## 4. API : Définition des Endpoints (REST) + +### Core Loop (Jeu) +- `GET /api/zoo/me` : Récupère l'état complet du zoo (déclenche le Lazy Update). +- `POST /api/zoo/move` : Déplace un élément (Animal/Bâtiment). + - Body: `{ "entity_id": "uuid", "x": 10, "y": 5 }` +- `POST /api/zoo/feed` : Nourrit les animaux (Global ou Ciblé). + +### Économie & Marché +- `GET /api/market/auctions` : Liste les enchères actives (Filtres: Type, Rareté). +- `POST /api/market/bid` : Placer une offre. + - Body: `{ "auction_id": "uuid", "amount": 500 }` +- `POST /api/market/sell` : Créer une enchère. + +### Système +- `POST /api/auth/login` : Authentification par clé. +- `GET /api/config` : Récupère les tables statiques (Animaux, Coûts, Saisons) pour le cache client. + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Chargement (Feedback) +**Description UX** : Le joueur attend le calcul du Lazy Update à la connexion. +**Description UI** : Écran de chargement avec barre de progression ou animation (Animal qui marche). +**Emplacement** : Plein écran (Démarrage). +**Intégration** : Bloquant. +**Navigation** : N/A +**Événements** : `APP_LOAD`. + +#### Assets +- **Musiques** : Thème Principal. +- **Sons** : N/A +- **Graphiques** : Logo Jeu. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Loader. +- **Couleurs** : Thème Jeu. +- **Textes** : "Calcul de la simulation...", "Rattrapage du temps...". +- **Formes** : N/A + +### Erreur Connexion (Feedback) +**Description UX** : Perte de connexion ou erreur API. +**Description UI** : Toast ou Modal "Erreur Réseau". Bouton "Réessayer". +**Emplacement** : Overlay. +**Intégration** : Bloquant ou Non-bloquant. +**Navigation** : Clic Réessayer. +**Événements** : `NETWORK_ERROR`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `error.mp3`. +- **Graphiques** : Icône Wifi barré. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Secousse. +- **Couleurs** : Rouge. +- **Textes** : "Connexion perdue". +- **Formes** : N/A diff --git a/docs/specs/temperature.md b/docs/specs/temperature.md new file mode 100644 index 0000000..16e2cda --- /dev/null +++ b/docs/specs/temperature.md @@ -0,0 +1,149 @@ +# Spécifications : Température + +## Définition +Paramètre environnemental lié au milieu (couleur) de la case. + +## Impact +- **Survie** : Chaque animal a une plage de température idéale. + - **Trop Froid** : L'animal gèle (feedback givre/bleu). Risque de mort. + - **Trop Chaud** : L'animal surchauffe (feedback vapeur/rouge). Risque de mort. +- **Reproduction** : Une température idéale est requise pour la reproduction optimale. + +## Modification +- La température est intrinsèque à la case (liée au biome). +- **Upgrade** : L'achat d'un "Changement de milieu (température choisie)" permet d'ajuster la température d'une case spécifique pour l'adapter à un animal. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Propriété de `Case`. +`temperature: float` + +### Caractéristiques Initiales +Définie par le biome (voir `milieu.md`). + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A (C'est la définition même). + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +| Saison | Modificateur Global | +| :--- | :--- | +| Printemps | +0°C | +| Été | +10°C | +| Automne | -2°C | +| Hiver | -15°C | + +### Impact Heure / Jour-Nuit +| Cycle | Modificateur | +| :--- | :--- | +| Jour | +5°C (Midi) | +| Nuit | -5°C (Minuit) | + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +**Fenêtre Fertilité** : +/- 5°C autour de l'idéal de l'animal. + +### Impact Mort +**Seuil Critique** : +/- 20°C autour de l'idéal = Dégâts massifs. + +### Impact Nourriture +**Froid** : Augmente la consommation de nourriture (+20% par tranche de 5°C sous l'idéal). + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Canicule** : +10°C temporaire. +* **Vague de Froid** : -10°C temporaire. + +## 7. Progression +### Tableau des Upgrades +**Régulateur Thermique (Case)** : +| Niveau | Coût | Plage Réglage | Temps | +| :--- | :--- | :--- | :--- | +| 1 | 200 | +/- 5°C | 0s | +| 7 | 2000 | +/- 50°C | 0s | + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +currentTemp = baseBiomeTemp + seasonMod + dayNightMod + caseRegulatorOffset +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `TEMP_CRITICAL` | Alerte | "Température critique sur une case !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Visualisation Thermique (Passif) +**Description UX** : Le joueur voit la température de chaque case. +**Description UI** : Mode "Vue Thermique" (Overlay). Cases froides = Bleu, Chaudes = Rouge. +**Emplacement** : Bouton HUD "Filtres". +**Intégration** : Overlay global. +**Navigation** : Toggle On/Off. +**Événements** : `TOGGLE_THERMAL_VIEW`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `switch.mp3`. +- **Graphiques** : Overlay dégradé Bleu/Rouge semi-transparent. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Fade In/Out de l'overlay. +- **Couleurs** : Bleu (#0000FF) -> Rouge (#FF0000). +- **Textes** : "25°C" (sur chaque case). +- **Formes** : N/A + +### Feedback Critique (Alerte) +**Description UX** : Une case est mortelle pour son occupant. +**Description UI** : La case clignote ou a un bord rouge. L'animal tremble (froid) ou transpire (chaud). +**Emplacement** : Case. +**Intégration** : Immédiat. +**Navigation** : N/A +**Événements** : `TEMP_ALERT`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `shiver.mp3` (dents qui claquent) ou `pant.mp3`. +- **Graphiques** : Particules Givre ou Vapeur. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Tremblement sprite. +- **Couleurs** : Bleu Glace / Rouge Feu. +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/vente_animal.md b/docs/specs/vente_animal.md new file mode 100644 index 0000000..f7efdb9 --- /dev/null +++ b/docs/specs/vente_animal.md @@ -0,0 +1,107 @@ +# Spécifications : Ventes d'Animaux (Général) + +## Types de Ventes +- **Aux Enchères** : Vers d'autres joueurs (prix potentiellement élevé). +- **Libération (Vente rapide)** : Vente au "système" (prix bas fixe) pour faire de la place rapidement (si implémenté pour éviter la mort). *Note : Le cahier des charges privilégie le marché joueurs, la libération est une option de secours.* + +## Contraintes +- Un animal malade ou mourant ne peut pas être mis en vente. +- Le transport (Camion) immobilise l'animal pendant la durée de la vente. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +N/A + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +N/A + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +**Prix Libération** : 10% de la valeur théorique de l'animal. + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Libération (Action) +**Description UX** : Le joueur relâche un animal (vente système). +**Description UI** : Bouton "Libérer" (souvent rouge ou discret pour éviter les erreurs). Confirmation "Êtes-vous sûr ? Vous gagnerez peu (X pièces)". +**Emplacement** : Panneau Animal. +**Intégration** : Option secondaire. +**Navigation** : Clic Libérer -> Confirmer. +**Événements** : `RELEASE_ANIMAL`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `bird_fly_away.mp3` (bruit de nature positif). +- **Graphiques** : Icône Cage ouverte. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Animal s'estompe ou court hors de l'écran. +- **Couleurs** : Vert (Nature). +- **Textes** : "Libéré !". +- **Formes** : N/A diff --git a/docs/specs/vente_enchere_animal.md b/docs/specs/vente_enchere_animal.md new file mode 100644 index 0000000..cc6b707 --- /dev/null +++ b/docs/specs/vente_enchere_animal.md @@ -0,0 +1,128 @@ +# Spécifications : Vente aux Enchères d'Animaux + +## Définition +Processus de vente pour les animaux adultes. + +## Spécificités +- **Valeur Variable** : Dépend de la rareté, mais aussi de l'âge et du "Score de Reproduction" restant. Un vieil animal vaut moins cher. +- **Résistance** : Les adultes supportent mieux la période de mise en vente que les bébés. + +## Processus +Identique à la vente de bébés, mais avec des tolérances plus grandes sur les délais avant impact négatif sur la santé de l'animal. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +Identique à `vente_enchere_bebe.md`. + +### Caractéristiques Initiales +N/A + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Action joueur. + +### Conditions de Disparition +Vente ou Expiration. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +**Risque** : Si `status == expired` et non récupéré sous 24h -> Mort. + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Mise en Vente (Action) +**Description UX** : Le joueur sélectionne un animal adulte sur la grille et choisit "Vendre". +**Description UI** : Bouton contextuel "Vendre" sur le panneau animal. Ouvre la **Bottom Sheet** de configuration (Prix, Durée). +**Emplacement** : Panneau Animal -> Modal Vente. +**Intégration** : Contextuel. +**Navigation** : Clic Animal -> Vendre -> Config -> Valider. +**Événements** : `CREATE_AUCTION_ADULT`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `cash_register_open.mp3`. +- **Graphiques** : N/A +- **Images** : Portrait Animal. +- **Vidéos** : N/A +- **Animations** : Animal disparaît de la grille (part en zone de vente). +- **Couleurs** : N/A +- **Textes** : "Mise à prix". +- **Formes** : N/A + +### Retour Invendu (Action) +**Description UX** : L'enchère est finie sans acheteur. Le joueur doit récupérer l'animal. +**Description UI** : Notification "Vente expirée". Bouton "Récupérer" dans le menu Ventes. +**Emplacement** : Menu Ventes -> Accueil Animaux. +**Intégration** : Action requise. +**Navigation** : Clic Récupérer -> Choix Case (si Accueil plein). +**Événements** : `RECLAIM_ANIMAL`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `sad_trombone.mp3` (échec). +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Retour visuel de l'animal. +- **Couleurs** : Gris (Désactivé). +- **Textes** : "Invendu". +- **Formes** : N/A diff --git a/docs/specs/vente_enchere_bebe.md b/docs/specs/vente_enchere_bebe.md new file mode 100644 index 0000000..d8fe899 --- /dev/null +++ b/docs/specs/vente_enchere_bebe.md @@ -0,0 +1,153 @@ +# Spécifications : Vente aux Enchères de Bébés + +## Définition +Processus de vente spécifique pour les bébés animaux nés dans le zoo. + +## Spécificités +- **Forte Valeur** : Les bébés ont souvent une valeur supérieure aux adultes car ils ont tout leur potentiel de vie et de reproduction devant eux. +- **Fragilité** : Un bébé mis en vente doit être vendu rapidement. +- **Risque** : Si l'enchère échoue ou dure trop longtemps sans acheteur, le bébé risque de mourir dans le camion ou la zone de vente (faim/froid). + +## Processus +1. Sélection du bébé en Nurserie ou sur la grille. +2. Glisser-déposer vers la zone Camion. +3. Définition du prix de départ. +4. Apparition sur la Carte du Monde. +5. Si vendu : Transfert après délai de 10 min. +6. Si invendu : Retour nécessaire en Nurserie ou Mort. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "seller_id": "uuid", + "animal_id": "uuid", + "start_price": "int", + "current_bid": "int", + "highest_bidder": "uuid | null", + "end_time": "timestamp", + "status": "active | sold | expired" +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur | +| :--- | :--- | +| Durée Enchère | 1h - 24h (au choix) | +| Délai Validation | 10 min (fixe) | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Action joueur. + +### Conditions de Disparition +Vente ou Expiration. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +**Risque** : Si `status == expired` et non récupéré sous 1h -> Mort du bébé. + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +N/A + +### Impact Valeur +**Dépréciation** : Aucune (c'est une enchère). + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +N/A + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Nouvelle Enchère** : Notification. +* **Surenchère** : Notification vendeur. +* **Vente Terminée** : Notification. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `AUCTION_SOLD` | Succès | "Bébé vendu !" | +| `AUCTION_FAIL` | Alerte | "Enchère terminée sans acheteur. Récupérez le bébé !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Mise en Vente (Action) +**Description UX** : Le joueur glisse un bébé depuis la nurserie vers le camion. +**Description UI** : **Bottom Sheet** de configuration "Vendre ce bébé". Choix du prix de départ et de la durée. +**Emplacement** : Zoo -> Modal Vente. +**Intégration** : Formulaire. +**Navigation** : Drag -> Config -> Valider. +**Événements** : `CREATE_AUCTION`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `cash_register_open.mp3`. +- **Graphiques** : Icône Bourse/Pièce. +- **Images** : Portrait Bébé. +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Or. +- **Textes** : "Prix de départ", "Durée". +- **Formes** : Champs de saisie. + +### Suivi Vente (Passif) +**Description UX** : Le joueur voit son offre active sur son propre zoo (Carte Monde) ou dans son menu "Ventes". +**Description UI** : Liste "Mes Ventes". Compteur d'enchères reçues. +**Emplacement** : Menu Gestion -> Ventes. +**Intégration** : Liste. +**Navigation** : Menu -> Ventes. +**Événements** : `OPEN_MY_SALES`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `notification.mp3` (si offre reçue). +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : N/A +- **Textes** : "Meilleure offre : X". +- **Formes** : N/A diff --git a/docs/specs/ville.md b/docs/specs/ville.md new file mode 100644 index 0000000..4a18e1a --- /dev/null +++ b/docs/specs/ville.md @@ -0,0 +1,123 @@ +# Spécifications : Ville (Site) + +## Définition +Une ville est une entité sur la Carte du Monde qui génère le flux de visiteurs vers les zoos. + +## Propriétés +- **Nom** : Nom de la ville. +- **Population** : Nombre de visiteurs potentiels maximum. +- **Icône** : 🏙️ + +## Mécanique d'Attraction +- Les visiteurs partent de la ville pour aller vers les zoos. +- **Facteur de Distance** : Plus un zoo est proche d'une ville, plus il attire de visiteurs. +- **Facteur d'Attractivité** : La valeur et la diversité du zoo influencent le choix des visiteurs. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "city", + "name": "string", + "population": "integer", + "wealth_factor": "float (0.5 - 2.0)" +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Moyenne | +| :--- | :--- | +| Population | 10,000 - 1,000,000 | +| Richesse | 1.0 | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Génération carte. + +### Conditions de Disparition +N/A + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +Source du flux. `Flux = Pop * (AttractivitéZoo / Distance²)`. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +**Facteur Richesse** : Multiplie le budget des visiteurs issus de cette ville. + +### Trajet Visiteurs +Point de départ. + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +N/A + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Visualisation Taille (Passif) +**Description UX** : Distinguer les grandes villes des petites. +**Description UI** : Taille de l'icône proportionnelle à la population. Gratte-ciels vs Maisons. +**Emplacement** : Carte Monde. +**Intégration** : Sprite. +**Navigation** : N/A +**Événements** : N/A + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Sprites Ville (Petit, Moyen, Grand). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : Gris/Bleu. +- **Textes** : Nom Ville. +- **Formes** : N/A diff --git a/docs/specs/visiteur.md b/docs/specs/visiteur.md new file mode 100644 index 0000000..b86519e --- /dev/null +++ b/docs/specs/visiteur.md @@ -0,0 +1,228 @@ +# Spécifications : Visiteur + +## Définition +Personnage non-joueur (PNJ) qui visite le zoo. + +## Comportement +- **Apparition** : Arrive par la Billeterie (venant d'une Ville). +- **Déplacement** : Se promène sur la grille, attiré par les animaux et boutiques. +- **Durée** : Reste jusqu'à 1 journée (variable selon attractivité). +- **Dépense** : Paie l'entrée + achats en boutique. + +## Incidents et Exigences +- **Besoins** : Soif, Repos (banc), Propreté (poubelle), Envie de photo. +- **Feedback** : Bulle d'icône au-dessus du visiteur. +- **Résolution** : Clic du joueur sur la bulle. +- **Conséquence** : + - Résolu : Gain de pièces + Attractivité. + - Ignoré : Perte d'attractivité, départ anticipé. +- **Contexte** : Apparaissent plus souvent pendant les temps d'attente (camion/enchère). + +## Types +- **Standard** +- **Luxe** (8%) : Paie plus cher, plus exigeant. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid_v4", + "type": "enum ('standard', 'vip')", + "entry_time": "timestamp", + "max_duration": "integer (seconds)", + "budget": "integer", + "satisfaction": "float (0-100)", + "needs": { + "thirst": "float (0-100)", + "rest": "float (0-100)", + "toilet": "float (0-100)" + }, + "current_target": { "x": "int", "y": "int" }, + "position": { "x": "int", "y": "int" } +} +``` + +### Caractéristiques Initiales +| Caractéristique | Standard | Luxe (VIP) | +| :--- | :--- | :--- | +| Budget | 50 | 200 | +| Durée Max | 300s (5 min) | 600s (10 min) | +| Satisfaction | 50 | 50 | + +### Scores Initiaux +N/A + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +| Facteur | Taux | +| :--- | :--- | +| Attractivité Zoo | `log(Attractivité) * FacteurVille` | +| Heure | Pic à 14h, Nul à 22h | + +### Conditions de Disparition +| Cause | Seuil | +| :--- | :--- | +| Temps écoulé | Durée > Max Duration | +| Insatisfaction | Satisfaction <= 0 | +| Budget épuisé | Budget <= 0 (Départ normal) | + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +| Météo | Effet | +| :--- | :--- | +| Canicule | Soif augmente 2x plus vite | +| Pluie | Satisfaction baisse plus vite (sauf si abri) | + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +| Saison | Affluence (Multiplicateur) | +| :--- | :--- | +| Printemps | x1.0 | +| Été | x1.5 (Vacances) | +| Automne | x0.8 | +| Hiver | x0.6 | + +### Impact Heure / Jour-Nuit +| Heure | Flux Entrant | +| :--- | :--- | +| 08h-10h | Faible | +| 10h-16h | Fort | +| 16h-18h | Décroissant | +| > 18h | Nul | + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +N/A + +### Impact Mort +| Événement | Effet Satisfaction | +| :--- | :--- | +| Voir animal mort | -50 (Départ quasi immédiat) | + +### Impact Nourriture +N/A (Concerne les animaux, ici c'est "Soif/Faim visiteur" -> Boutiques) + +### Impact Attractivité (Visiteurs/Animaux) +| Cible | Poids Attraction | +| :--- | :--- | +| Animal Légendaire | 100 | +| Animal Commun | 10 | +| Boutique | 50 (si besoin > 50) | + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +| Paramètre | Valeur | +| :--- | :--- | +| Vitesse Marche | 1 case / 2 sec | + +### Dépenses (Boutiques/Visiteurs) +| Action | Dépense | +| :--- | :--- | +| Ticket Entrée | 10 (Standard) / 50 (VIP) | +| Boutique Souvenir | 15-30 | +| Boisson | 5 | + +### Trajet Visiteurs +**Algorithme** : `Weighted Random Walk` vers les points d'intérêt (POI) avec decay sur la distance. + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Incident (Bulle)** : Apparition aléatoire (probabilité augmentée si attente joueur). +* **Achat** : Interaction avec boutique. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +``` +function updateVisitor(visitor): + visitor.satisfaction -= decayRate + visitor.needs.thirst += 1 + + if visitor.needs.thirst > 80: + target = findNearestShop() + if not target: + visitor.satisfaction -= 5 (Frustration) + spawnIncident("Soif") + + if visitor.satisfaction <= 0: + leaveZoo(visitor) +``` + +### Messages d'Infos / Alerte +| ID | Niveau | Message | +| :--- | :--- | :--- | +| `VISITOR_ANGRY` | Info | "Un visiteur est parti mécontent." | +| `VISITOR_HAPPY` | Info | "Un visiteur a adoré le zoo !" | + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Visite (Passif) +**Description UX** : Le visiteur entre, marche, regarde les animaux, achète, repart. +**Description UI** : Petit sprite humain. Vêtements colorés. +**Emplacement** : Grille Zoo. +**Intégration** : Foule. +**Navigation** : N/A +**Événements** : `VISITOR_WALK`. + +#### Assets +- **Musiques** : N/A +- **Sons** : Pas sur l'herbe/béton. +- **Graphiques** : Sprites variés (H/F/Enfant). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Marche, Regarde (tourne la tête), Prend photo (flash). +- **Couleurs** : Aléatoires. +- **Textes** : N/A +- **Formes** : N/A + +### Incident / Besoin (Action) +**Description UX** : Une bulle apparaît au-dessus d'un visiteur (ex: Soif, Perdu, Envie pipi). Le joueur clique pour résoudre (ex: Indiquer toilettes, Donner eau). +**Description UI** : Bulle blanche avec icône noire. Clignote si urgent. +**Emplacement** : Au-dessus du sprite visiteur. +**Intégration** : Overlay World Space. +**Navigation** : Clic Bulle -> Résolution (Animation + Gain). +**Événements** : `INCIDENT_SPAWN`, `INCIDENT_SOLVE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `bubble_pop.mp3` (apparition), `coin_collect.mp3` (résolution). **Design ASMR** : Son satisfaisant et tactile ("Pop" organique). +- **Graphiques** : Icônes (Goutte d'eau, Point interrogation, Appareil photo). +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Bulle qui pop. Visiteur qui sourit après résolution. +- **Couleurs** : Bulle Blanche/Rouge (Urgent). +- **Textes** : "Merci !" (flottant). +- **Formes** : Bulle BD. + +### Départ (Passif) +**Description UX** : Le visiteur quitte le zoo. +**Description UI** : Marche vers la sortie et disparaît (Fade out). +**Emplacement** : Billeterie. +**Intégration** : Fin cycle. +**Navigation** : N/A +**Événements** : `VISITOR_EXIT`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : N/A +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : Fade out. +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/docs/specs/zoo.md b/docs/specs/zoo.md new file mode 100644 index 0000000..d09ace5 --- /dev/null +++ b/docs/specs/zoo.md @@ -0,0 +1,153 @@ +# Spécifications : Zoo (Site) + +## Définition +Représentation d'un zoo (joueur ou bot) sur la Carte du Monde. + +## Affichage +- **Nom du zoo** +- **Icône** : 🏠 +- **Slot d'offre** : + - Pour les autres zoos : Affiche l'œuf/bébé/animal en vente (Type + Prix). + - Pour le zoo du joueur : Masqué (pas d'offre visible pour soi-même). +- **Indicateurs (Bots)** : Pièces disponibles, Niveau de parcelle. + +## Indicateurs de Performance (Cases dédiées sous le zoo) +1. **Score d'Attractivité** : Influence la venue des visiteurs. +2. **Score de Reproduction** : Influence la qualité des naissances. +3. **Case de Vente** : Offre active visible par les autres joueurs. + +## Interaction +- **Achat** : Glisser l'offre du zoo vers le camion du joueur. + +# Annexes Techniques + +## 1. Données et États +### Modèle de Données (JSON) +```json +{ + "id": "uuid", + "type": "zoo", + "owner_id": "uuid", + "scores": { + "attractiveness": "int", + "reproduction": "int" + }, + "current_offer": { "animal_id": "uuid", "price": "int" } +} +``` + +### Caractéristiques Initiales +| Caractéristique | Valeur Initiale | +| :--- | :--- | +| Attractivité | 0 | +| Reproduction | 0 | + +### Scores Initiaux +Voir ci-dessus. + +## 2. Cycles de Vie et Apparition +### Conditions d'Apparition +Création compte. + +### Conditions de Disparition +Suppression compte. + +### Hérédité +N/A + +## 3. Impacts Environnementaux +### Impact Température +N/A + +### Impact Milieu (Biome) +N/A + +### Impact Saisons +N/A + +### Impact Heure / Jour-Nuit +N/A + +## 4. Impacts Biologiques et Sociaux +### Impact Reproduction +Score visible publiquement. + +### Impact Mort +N/A + +### Impact Nourriture +N/A + +### Impact Attractivité (Visiteurs/Animaux) +Score visible publiquement. + +### Impact Valeur +N/A + +## 5. Impacts Logistiques et Économiques +### Vitesse (Camion/Nurserie/Accueil/Recherche/Labo/Visite) +N/A + +### Dépenses (Boutiques/Visiteurs) +N/A + +### Trajet Visiteurs +Destination possible. + +## 6. Événements +### Événements du Jeu / Carte / Zoo / Ville / Visiteur +* **Nouvelle Offre** : Mise en vente. +* **Vente** : Offre achetée. + +## 7. Progression +### Tableau des Upgrades +N/A + +## 8. Logique et Interfaces +### Pseudo-code Impacts +N/A + +### Messages d'Infos / Alerte +N/A + +# Annexes UX/UI + +## 1. Expérience Utilisateur (UX) +### Visualisation Offre (Passif) +**Description UX** : Voir ce que vend un autre joueur. +**Description UI** : Une "bulle" ou un "panneau" accroché sous l'icône du zoo. Affiche l'animal (sprite) et le prix. +**Emplacement** : Carte Monde. +**Intégration** : World Space UI. +**Navigation** : N/A +**Événements** : `OFFER_UPDATE`. + +#### Assets +- **Musiques** : N/A +- **Sons** : N/A +- **Graphiques** : Cadre Offre. +- **Images** : Sprite Animal. +- **Vidéos** : N/A +- **Animations** : Prix qui clignote si bas. +- **Couleurs** : Or (Prix). +- **Textes** : "500". +- **Formes** : Bulle. + +### Interaction Achat (Action) +**Description UX** : Acheter l'animal. +**Description UI** : Drag de l'offre vers le camion (en bas de l'écran ou sur la carte). +**Alternative** : Tap Offre -> Tap Camion. +**Emplacement** : Carte Monde. +**Intégration** : Drag & Drop. +**Navigation** : Drag -> Drop. +**Événements** : `DRAG_OFFER`. + +#### Assets +- **Musiques** : N/A +- **Sons** : `pickup.mp3`. +- **Graphiques** : Fantôme offre sous curseur. +- **Images** : N/A +- **Vidéos** : N/A +- **Animations** : N/A +- **Couleurs** : N/A +- **Textes** : N/A +- **Formes** : N/A diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f757476 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,103 @@ +/** + * ESLint flat config – project rules (Rappel des grandes règles / qualité du code). + * Applies to web/js and server (excluding node_modules). + * Run: npm run lint + */ +const js = require("@eslint/js"); +const jsdoc = require("eslint-plugin-jsdoc"); + +module.exports = [ + js.configs.recommended, + { + files: ["web/js/**/*.js", "server/**/*.js"], + ignores: ["**/node_modules/**", "server/node_modules/**"], + plugins: { jsdoc: jsdoc }, + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + window: "readonly", + document: "readonly", + localStorage: "readonly", + fetch: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + performance: "readonly", + console: "readonly", + URL: "readonly", + URLSearchParams: "readonly", + Blob: "readonly", + btoa: "readonly", + atob: "readonly", + crypto: "readonly", + TextEncoder: "readonly", + navigator: "readonly", + CustomEvent: "readonly", + Event: "readonly", + HTMLElement: "readonly", + Node: "readonly", + Element: "readonly", + MutationObserver: "readonly", + ResizeObserver: "readonly", + requestAnimationFrame: "readonly", + cancelAnimationFrame: "readonly", + getComputedStyle: "readonly", + addEventListener: "readonly", + removeEventListener: "readonly", + dispatchEvent: "readonly", + }, + }, + rules: { + "max-lines": ["error", { max: 250, skipBlankLines: true, skipComments: true }], + "max-lines-per-function": ["error", { max: 40, skipBlankLines: true, skipComments: true }], + "max-params": ["error", 4], + "max-depth": ["error", 4], + "complexity": ["warn", { max: 10 }], + "no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + "eqeqeq": ["error", "always"], + "no-implicit-coercion": "warn", + "no-var": "error", + "prefer-const": "warn", + "no-else-return": "warn", + "no-continue": "error", + "no-labels": "error", + "prefer-arrow-callback": "warn", + "no-param-reassign": ["error", { props: false }], + "no-throw-literal": "error", + "require-await": "warn", + "no-console": ["warn", { allow: ["warn", "error"] }], + "no-debugger": "error", + "no-alert": "error", + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + "no-constructor-return": "error", + "no-duplicate-imports": "warn", + "no-unreachable": "warn", + "no-shadow": "warn", + "jsdoc/require-returns": ["warn", { forceReturnsWithAsync: true }], + "jsdoc/check-param-names": "warn", + "jsdoc/require-param-description": "off", + "jsdoc/require-returns-description": "off", + }, + }, + { + files: ["server/**/*.js"], + ignores: ["server/node_modules/**"], + languageOptions: { + globals: { + process: "readonly", + __dirname: "readonly", + __filename: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + }, + }, + }, +]; diff --git a/nginx/evozoo.4nkweb.com.conf b/nginx/evozoo.4nkweb.com.conf new file mode 100644 index 0000000..2f3c37a --- /dev/null +++ b/nginx/evozoo.4nkweb.com.conf @@ -0,0 +1,32 @@ +# Evo Zoo — reverse proxy to game server (192.168.1.173:8080) +# Deploy on proxy (192.168.1.100). Enable: sudo ln -sf /path/to/this/file /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl reload nginx +# Cert: sudo certbot --nginx -d evozoo.4nkweb.com + +server { + listen 80; + listen [::]:80; + server_name evozoo.4nkweb.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name evozoo.4nkweb.com; + + ssl_certificate /etc/letsencrypt/live/evozoo.4nkweb.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/evozoo.4nkweb.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://192.168.1.173:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5365301 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1219 @@ +{ + "name": "builazoo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "builazoo", + "version": "1.0.0", + "devDependencies": { + "@eslint/js": "^9.15.0", + "eslint": "^9.15.0", + "eslint-plugin-jsdoc": "^50.0.0" + } + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.50.2", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", + "integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6", + "@typescript-eslint/types": "^8.11.0", + "comment-parser": "1.4.1", + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "50.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.8.0.tgz", + "integrity": "sha512-UyGb5755LMFWPrZTEqqvTJ3urLz1iqj+bYOHFNag+sw3NvaMWP9K2z+uIn37XfNALmQLQyrBlJ5mkiVPL7ADEg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.50.2", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.4.1", + "escape-string-regexp": "^4.0.0", + "espree": "^10.3.0", + "esquery": "^1.6.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.2", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", + "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c05c20d --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "builazoo", + "version": "1.0.0", + "private": true, + "description": "Build a Zoo - web and server", + "scripts": { + "lint": "eslint web/js server --ignore-pattern '**/node_modules/**'", + "lint:web": "eslint web/js", + "lint:server": "eslint server --ignore-pattern 'server/node_modules/**'" + }, + "devDependencies": { + "eslint": "^9.15.0", + "eslint-plugin-jsdoc": "^50.0.0", + "@eslint/js": "^9.15.0" + } +} diff --git a/scripts/remove-code-blocks.js b/scripts/remove-code-blocks.js new file mode 100644 index 0000000..c575133 --- /dev/null +++ b/scripts/remove-code-blocks.js @@ -0,0 +1,97 @@ +/** + * Removes code blocks from cahier des charges: + * - Lua blocks (-- path, local ..., return Module) + * - weightedPick function block + * - Console/stack trace blocks + */ +const fs = require('fs'); +const path = require('path'); + +const filePath = path.join(__dirname, '..', 'docs', 'cahier des charges.md'); +let lines = fs.readFileSync(filePath, 'utf8').split('\n'); +const out = []; +let i = 0; +const n = lines.length; + +function isLuaBlockStart(line) { + return ( + line.startsWith('-- ReplicatedStorage/') || + line.startsWith('-- ServerScriptService/') + ); +} + +function isLuaBlockEnd(line, forServerMain) { + if (forServerMain) { + return line === 'end)'; + } + return ( + line === 'return LootTables' || + line === 'return PlayerDataService' || + line === 'return ZooService' || + line === 'return IncomeService' + ); +} + +function isStackTraceLine(line) { + if (!line || line.trim() === '') return false; + return ( + /^\d{2}:\d{2}:\d{2}\.\d+ /.test(line) || + /\.(js|ts):\d+/.test(line) || + /^at \w+ \(/.test(line) || + /^(GET|POST) https:\/\//.test(line) || + /Fetch failed loading/.test(line) || + /^[a-zA-Z]+ @ (main|api-client|auth-client)\.js:\d+/.test(line) || + /^await in /.test(line) || + /\(anonymous\) @ /.test(line) || + /^Error: /.test(line) && /\.js:\d+/.test(line) + ); +} + +while (i < n) { + const line = lines[i]; + + // Lua block: -- ReplicatedStorage/ or -- ServerScriptService/ + if (isLuaBlockStart(line)) { + const forServerMain = line.includes('ServerMain'); + i++; + while (i < n && !isLuaBlockEnd(lines[i], forServerMain)) { + i++; + } + if (i < n) i++; + continue; + } + + // weightedPick function block + if (line.startsWith('local function weightedPick(rng, entries)')) { + i++; + while (i < n && lines[i] !== 'end') { + i++; + } + if (i < n) i++; + continue; + } + + // task.spawn(function() ... end) block (leftover Lua fragment) + if (line === 'task.spawn(function()') { + i++; + while (i < n && lines[i] !== 'end)') { + i++; + } + if (i < n) i++; + continue; + } + + // Consecutive stack trace lines + if (isStackTraceLine(line)) { + while (i < n && isStackTraceLine(lines[i])) { + i++; + } + continue; + } + + out.push(line); + i++; +} + +fs.writeFileSync(filePath, out.join('\n')); +console.log('Removed code blocks. Lines: ' + lines.length + ' -> ' + out.length); diff --git a/scripts/strip-technical-cahier.js b/scripts/strip-technical-cahier.js new file mode 100644 index 0000000..4ff66c0 --- /dev/null +++ b/scripts/strip-technical-cahier.js @@ -0,0 +1,46 @@ +/** + * Strip technical descriptions from cahier des charges: + * Remove lines that are file paths, config references, or "Fichiers modifiés" lists. + */ +const fs = require('fs'); +const path = require('path'); + +const baseDir = path.resolve(__dirname, '..'); +const filePath = path.join(baseDir, 'docs', 'cahier des charges.md'); +let content = fs.readFileSync(filePath, 'utf8'); +const lines = content.split('\n'); +const out = []; + +function isTechnicalLine(line) { + const t = line.trim(); + if (!t) return false; + if (/^[-*]?\s*`?web\/js\//.test(t) || /^[-*]?\s*`?server\//.test(t)) return true; + if (/^\*\*Fichiers (modifiés|impactés|concernés)/i.test(t)) return true; + if (/^-\s*`[^`]+\.(js|css|ts)`\s*[;,.]/.test(t)) return true; + if (/^\*\*Config\*\* `config\.js`/.test(t)) return true; + if (/^-\s*`web\/js\/[^`]+`\s*$/.test(t)) return true; + if (/^`web\/js\/[^`]+`,?\s*$/.test(t)) return true; + if (/^-\s*\*\*[a-z0-9-]+\.(js|ts|css)\*\*/.test(t) && t.length < 130) return true; + if (/^\d+\.\s+\*\*[a-z-]+\.(js|ts)\*\*/.test(t)) return true; + if (/^-\s*`[a-z0-9/-]+\.(js|css|ts)`\s*[,(]/.test(t)) return true; + return false; +} + +let i = 0; +while (i < lines.length) { + const line = lines[i]; + if (isTechnicalLine(line)) { + i++; + continue; + } + if (/^\*\*Fichiers (modifiés|impactés)/i.test(line.trim())) { + i++; + while (i < lines.length && (lines[i].trim().startsWith('-') || lines[i].trim() === '')) i++; + continue; + } + out.push(line); + i++; +} + +fs.writeFileSync(filePath, out.join('\n')); +console.log('Lines: ' + lines.length + ' -> ' + out.length); diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..adc69b6 --- /dev/null +++ b/server/README.md @@ -0,0 +1,40 @@ +# Build a Zoo — API server + +Node + Express + PostgreSQL. Auth by Ed25519 keypair (pseudo, no password). Zoos and game state persisted in DB; bots ensure minimum zoo density on the map. + +## Prerequisites + +- Node 18+ +- PostgreSQL (create a database, e.g. `builazoo`) + +## Setup + +```bash +npm install +createdb builazoo # or your DB name +psql -d builazoo -f schema.sql +``` + +Set environment: + +- `DATABASE_URL`: connection string (default `postgres://localhost/builazoo`) +- `PORT`: HTTP port (default `3000`) + +## Run + +```bash +npm start +``` + +API base URL is `http://localhost:3000` (or your host/port). From the web client, set `window.BUILAZOO_API_URL` to this URL before loading the app (e.g. in HTML or via build env) to enable sign-up and sync. For local testing you can also open the web app with `?api=http://localhost:3000` (or your API origin). + +## Endpoints + +- `GET /api/health` — health check +- `POST /api/auth/register` — create account (body: `{ pseudo }`, header: `X-Public-Key` base64url) +- `GET /api/zoos` — list zoos for map (creates bots if below `minZoos`) +- `GET /api/zoos/me` — get my zoo + game_state (signed) +- `POST /api/zoos/me` — create my zoo (signed, body: `{ name?, game_state }`) +- `PATCH /api/zoos/me` — update game_state (signed, body: `{ game_state }`) + +Signed requests use headers: `X-Public-Key`, `X-Signature`, `X-Timestamp`. Message signed = `method + path + timestamp + SHA256(body)`. diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..ee5d026 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,36 @@ +import crypto from "crypto"; + +export function verifySignature(publicKeyBase64, signatureBase64, message) { + try { + const publicKeyBuf = Buffer.from(publicKeyBase64, "base64url"); + const signatureBuf = Buffer.from(signatureBase64, "base64url"); + const keyObject = crypto.createPublicKey({ + key: publicKeyBuf, + format: "der", + type: "spki", + }); + return crypto.verify(null, Buffer.from(message, "utf8"), keyObject, signatureBuf); + } catch (_e) { + return false; + } +} + +/** Build message that client must sign: method + path + timestamp + bodyHash */ +/** + * @param {string} method + * @param {string} path + * @param {string} timestamp + * @param {string} bodyHash + * @returns {string} + */ +export function buildSignMessage(method, path, timestamp, bodyHash) { + return `${method}\n${path}\n${timestamp}\n${bodyHash}`; +} + +/** + * @param {string} body + * @returns {string} hex hash of body + */ +export function hashBody(body) { + return crypto.createHash("sha256").update(body || "").digest("hex"); +} diff --git a/server/bot-state.js b/server/bot-state.js new file mode 100644 index 0000000..a8f27bd --- /dev/null +++ b/server/bot-state.js @@ -0,0 +1,24 @@ +/** + * Server-side initial bot state. Mirrors web/js/bot-zoo.js createInitialBotState + * so the server does not depend on browser modules. + */ + +const PROFILE_OPTIONS = ["fast", "slow", "balanced"]; +const INITIAL_COINS_MIN = 150; +const INITIAL_COINS_MAX = 450; + +/** + * @returns {{ coins: number, plotLevel: number, conveyorLevel: number, truckLevel: number, profile: string, lastTickAt: number }} + */ +export function createInitialBotState() { + const coins = INITIAL_COINS_MIN + Math.floor(Math.random() * (INITIAL_COINS_MAX - INITIAL_COINS_MIN + 1)); + const profile = PROFILE_OPTIONS[Math.floor(Math.random() * PROFILE_OPTIONS.length)]; + return { + coins, + plotLevel: 1, + conveyorLevel: 1, + truckLevel: 1, + profile, + lastTickAt: 0, + }; +} diff --git a/server/bot-tick.js b/server/bot-tick.js new file mode 100644 index 0000000..914c07c --- /dev/null +++ b/server/bot-tick.js @@ -0,0 +1,58 @@ +/** + * Server-side bot tick: load bot zoos, run same logic as client tickBotZoos, persist. + * Uses web/js/bot-zoo.js so bot rules stay in one place. + */ + +import { getBotZoosForTick, updateBotZooState, expireSaleListings, getActiveSaleListings, placeBid } from "./db.js"; +import { tickBotZoos } from "../web/js/bot-zoo.js"; + +const BOT_TICK_INTERVAL_MS = 15 * 1000; +let lastTickAt = 0; + +/** + * Run one bot tick and persist all bot zoos. + * @returns {Promise} + */ +export async function runBotTick() { + const nowUnix = Math.floor(Date.now() / 1000); + const nowMs = Date.now(); + const dt = lastTickAt > 0 ? Math.min((nowMs - lastTickAt) / 1000, 60) : 10; + lastTickAt = nowMs; + + await expireSaleListings(); + + const botZoos = await getBotZoosForTick(); + if (botZoos.length === 0) return; + + const activeListings = await getActiveSaleListings(); + for (const listing of activeListings) { + const minBid = (listing.best_bid_amount ?? listing.initial_price) + 1; + const bidders = botZoos.filter( + (z) => z.id !== listing.seller_zoo_id && (z.botState?.coins ?? 0) >= minBid + ); + if (bidders.length > 0) { + const bidder = bidders[Math.floor(Math.random() * bidders.length)]; + const result = await placeBid(listing.id, bidder.id, minBid); + if (result.ok) break; + } + } + + const state = { worldZoos: botZoos }; + tickBotZoos(state, nowUnix, dt); + + for (const zoo of botZoos) { + await updateBotZooState(zoo.id, zoo.animalWeights, zoo.botState); + } +} + +/** + * Start the periodic bot tick (call after server listen). + * @returns {void} + */ +export function startBotTickInterval() { + setInterval(() => { + runBotTick().catch((err) => { + console.error("bot tick failed", err); + }); + }, BOT_TICK_INTERVAL_MS); +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..e6aa5c6 --- /dev/null +++ b/server/db.js @@ -0,0 +1,489 @@ +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 }; diff --git a/server/env.js b/server/env.js new file mode 100644 index 0000000..c47d29a --- /dev/null +++ b/server/env.js @@ -0,0 +1,10 @@ +/** + * Load .env from project root before any other module that uses process.env. + * Must be imported first in index.js. + */ +import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, "..", ".env"), override: true }); diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..2f2724b --- /dev/null +++ b/server/index.js @@ -0,0 +1,104 @@ +import "./env.js"; +import express from "express"; +import path from "path"; +import { fileURLToPath } from "url"; +import cors from "cors"; +import zoosRouter from "./routes/zoos.js"; +import salesRouter from "./routes/sales.js"; +import { createAccount } from "./db.js"; +import { startBotTickInterval } from "./bot-tick.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const webDir = path.join(__dirname, "..", "web"); + +const app = express(); +app.use(cors({ origin: true, credentials: true })); + +app.use( + "/api/zoos", + express.raw({ type: "*/*", limit: "2mb" }), + (req, res, next) => { + req.bodyRaw = + req.body && Buffer.isBuffer(req.body) ? req.body.toString("utf8") : ""; + try { + req.body = req.bodyRaw ? JSON.parse(req.bodyRaw) : {}; + } catch { + req.body = {}; + } + next(); + }, + zoosRouter +); + +app.use( + "/api/sales", + express.raw({ type: "*/*", limit: "2mb" }), + (req, res, next) => { + req.bodyRaw = + req.body && Buffer.isBuffer(req.body) ? req.body.toString("utf8") : ""; + try { + req.body = req.bodyRaw ? JSON.parse(req.bodyRaw) : {}; + } catch { + req.body = {}; + } + next(); + }, + salesRouter +); + +app.use( + "/api/auth", + express.raw({ type: "*/*", limit: "10kb" }), + (req, res, next) => { + req.bodyRaw = + req.body && Buffer.isBuffer(req.body) ? req.body.toString("utf8") : ""; + try { + req.body = req.bodyRaw ? JSON.parse(req.bodyRaw) : {}; + } catch { + req.body = {}; + } + next(); + } +); +app.post("/api/auth/register", (req, res, next) => { + const publicKey = req.headers["x-public-key"]; + const pseudo = req.body?.pseudo?.trim(); + if (!publicKey || !pseudo || pseudo.length < 2) { + res.status(400).json({ + error: "pseudo required (min 2 chars) and X-Public-Key", + }); + return; + } + createAccount(publicKey, pseudo) + .then((account) => { + res.status(201).json({ id: account.id, pseudo: account.pseudo }); + }) + .catch((err) => { + console.error("[auth/register]", err?.message ?? err); + if (err.code === "23505") { + res.status(409).json({ error: "pseudo or publicKey already used" }); + return; + } + next(err); + }); +}); + +app.get("/api/health", (req, res) => { + res.json({ ok: true }); +}); + +app.use(express.static(webDir)); +app.get("*", (req, res) => { + res.sendFile(path.join(webDir, "index.html")); +}); + +app.use((err, _req, res, _next) => { + console.error("[api]", err?.message ?? err); + res.status(500).json({ error: "Internal server error" }); +}); + +const port = Number(process.env.PORT) || 3000; +app.listen(port, () => { + console.warn(`builazoo API listening on port ${port}`); + startBotTickInterval(); +}); diff --git a/server/migrations/001_sale_listings.sql b/server/migrations/001_sale_listings.sql new file mode 100644 index 0000000..b2611fd --- /dev/null +++ b/server/migrations/001_sale_listings.sql @@ -0,0 +1,34 @@ +-- Phase 10: sale listings and bids (enchères) +-- Run after schema.sql (e.g. psql -d builazoo -f server/migrations/001_sale_listings.sql) + +CREATE TABLE IF NOT EXISTS sale_listings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + seller_zoo_id UUID NOT NULL REFERENCES zoos(id) ON DELETE CASCADE, + animal_id TEXT NOT NULL, + is_baby BOOLEAN NOT NULL, + initial_price INTEGER NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'sold', 'expired', 'rejected')), + best_bid_amount INTEGER, + best_bidder_zoo_id UUID REFERENCES zoos(id) ON DELETE SET NULL, + sold_at TIMESTAMPTZ, + reproduction_score_at_sale NUMERIC, + delivered_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS sale_listings_seller ON sale_listings(seller_zoo_id); +CREATE INDEX IF NOT EXISTS sale_listings_status ON sale_listings(status); +CREATE INDEX IF NOT EXISTS sale_listings_end_at ON sale_listings(end_at); +CREATE INDEX IF NOT EXISTS sale_listings_best_bidder ON sale_listings(best_bidder_zoo_id); + +CREATE TABLE IF NOT EXISTS sale_bids ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + listing_id UUID NOT NULL REFERENCES sale_listings(id) ON DELETE CASCADE, + bidder_zoo_id UUID NOT NULL REFERENCES zoos(id) ON DELETE CASCADE, + amount INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(listing_id, bidder_zoo_id) +); + +CREATE INDEX IF NOT EXISTS sale_bids_listing ON sale_bids(listing_id); diff --git a/server/migrations/002_sale_listings_validated_at.sql b/server/migrations/002_sale_listings_validated_at.sql new file mode 100644 index 0000000..15d0ddd --- /dev/null +++ b/server/migrations/002_sale_listings_validated_at.sql @@ -0,0 +1,12 @@ +-- BLOC 2: Deferred validation (10 min) for sales. Run after 001_sale_listings.sql. +-- Adds validated_at: when accept is called, sold_at = now(), validated_at = now() + 10 minutes. +-- Coins are transferred only when validated_at <= now() (processed by processValidatedSales). +-- Buyer can deliver only after status = 'validated'. + +ALTER TABLE sale_listings ADD COLUMN IF NOT EXISTS validated_at TIMESTAMPTZ; + +ALTER TABLE sale_listings DROP CONSTRAINT IF EXISTS sale_listings_status_check; +ALTER TABLE sale_listings ADD CONSTRAINT sale_listings_status_check + CHECK (status IN ('active', 'sold', 'expired', 'rejected', 'validated')); + +COMMENT ON COLUMN sale_listings.validated_at IS 'When the sale becomes validated (coins transfer allowed). Set at accept to now() + 10 minutes; after processValidatedSales runs, status becomes validated.'; diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..d4aa923 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1015 @@ +{ + "name": "builazoo-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "builazoo-server", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^17.3.1", + "express": "^4.21.0", + "pg": "^8.13.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..3ca212c --- /dev/null +++ b/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "builazoo-server", + "version": "1.0.0", + "description": "Build a Zoo web API (PostgreSQL, auth by keypair)", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^17.3.1", + "express": "^4.21.0", + "pg": "^8.13.0" + } +} diff --git a/server/routes/sales.js b/server/routes/sales.js new file mode 100644 index 0000000..891ce5e --- /dev/null +++ b/server/routes/sales.js @@ -0,0 +1,228 @@ +import express from "express"; +import { + getAccountByPublicKey, + getZooByAccountId, + getSalesForZoo, + getActiveSaleListings, + createSaleListing, + placeBid, + acceptSale, + rejectSale, + markSaleDelivered, + expireSaleListings, + processValidatedSales, +} from "../db.js"; +import { verifySignature, buildSignMessage, hashBody } from "../auth.js"; + +const router = express.Router(); +const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; + +function requireSignature() { + return (req, res, next) => { + const publicKey = req.headers["x-public-key"]; + const signature = req.headers["x-signature"]; + const timestamp = req.headers["x-timestamp"]; + if (!publicKey || !signature || !timestamp) { + 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) { + 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)) { + res.status(401).json({ error: "Invalid signature" }); + return; + } + getAccountByPublicKey(publicKey).then((account) => { + if (!account) { + res.status(401).json({ error: "Unknown account" }); + return; + } + req.account = account; + next(); + }).catch(next); + }; +} + +function optionalSignature() { + return (req, res, next) => { + const publicKey = req.headers["x-public-key"]; + const signature = req.headers["x-signature"]; + const timestamp = req.headers["x-timestamp"]; + if (!publicKey || !signature || !timestamp) { + 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)) { + next(); + return; + } + getAccountByPublicKey(publicKey).then((account) => { + req.account = account ?? undefined; + next(); + }).catch(next); + }; +} + +/** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only. */ +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 }); + } catch (e) { + next(e); + } +}); + +/** POST /api/sales — create listing (auth). Body: { animalId, isBaby, price, endAt, reproductionScoreAtSale? }. */ +router.post("/", requireSignature(), async (req, res, next) => { + try { + const zoo = await getZooByAccountId(req.account.id); + if (!zoo) { + 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)" }); + return; + } + const { id } = await createSaleListing({ + sellerZooId: zoo.id, + animalId: animalId.trim(), + isBaby, + initialPrice, + endAt: endAtDate.toISOString(), + reproductionScoreAtSale: reproductionScoreAtSale != null ? Number(reproductionScoreAtSale) : undefined, + }); + res.status(201).json({ id }); + } catch (e) { + next(e); + } +}); + +/** POST /api/sales/:id/bid — place bid (auth). Body: { amount }. */ +router.post("/:id/bid", requireSignature(), async (req, res, next) => { + try { + const zoo = await getZooByAccountId(req.account.id); + if (!zoo) { + res.status(404).json({ error: "No zoo for this account" }); + return; + } + const amount = Number(req.body?.amount); + if (!Number.isFinite(amount) || amount < 0) { + res.status(400).json({ error: "amount must be a non-negative number" }); + return; + } + const result = await placeBid(req.params.id, zoo.id, amount); + if (!result.ok) { + res.status(400).json({ error: result.reason ?? "Bid failed" }); + return; + } + res.json({ ok: true }); + } catch (e) { + next(e); + } +}); + +/** POST /api/sales/:id/accept — seller accepts best bid (auth). */ +router.post("/:id/accept", requireSignature(), async (req, res, next) => { + try { + const zoo = await getZooByAccountId(req.account.id); + if (!zoo) { + res.status(404).json({ error: "No zoo for this account" }); + return; + } + const result = await acceptSale(req.params.id, zoo.id); + if (!result.ok) { + res.status(400).json({ error: result.reason ?? "Accept failed" }); + return; + } + res.json({ ok: true }); + } catch (e) { + next(e); + } +}); + +/** POST /api/sales/:id/reject — seller rejects current best bid (auth). */ +router.post("/:id/reject", requireSignature(), async (req, res, next) => { + try { + const zoo = await getZooByAccountId(req.account.id); + if (!zoo) { + res.status(404).json({ error: "No zoo for this account" }); + return; + } + const result = await rejectSale(req.params.id, zoo.id); + if (!result.ok) { + res.status(400).json({ error: result.reason ?? "Reject failed" }); + return; + } + res.json({ ok: true }); + } catch (e) { + next(e); + } +}); + +/** POST /api/sales/:id/deliver — buyer marks as delivered (auth). */ +router.post("/:id/deliver", requireSignature(), async (req, res, next) => { + try { + const zoo = await getZooByAccountId(req.account.id); + if (!zoo) { + res.status(404).json({ error: "No zoo for this account" }); + return; + } + const result = await markSaleDelivered(req.params.id, zoo.id); + if (!result.ok) { + res.status(400).json({ error: result.reason ?? "Deliver failed" }); + return; + } + res.json({ ok: true }); + } catch (e) { + next(e); + } +}); + +export default router; diff --git a/server/routes/zoos.js b/server/routes/zoos.js new file mode 100644 index 0000000..7a85034 --- /dev/null +++ b/server/routes/zoos.js @@ -0,0 +1,143 @@ +import express from "express"; +import { + getAccountByPublicKey, + getZooByAccountId, + createZoo, + getMapParams, + getAllZoos, + countPlayerZoos, + createBotZoo, + updateLastSeen, + updateZooGameState, +} from "../db.js"; +import { verifySignature, buildSignMessage, hashBody } from "../auth.js"; + +const router = express.Router(); +const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; + +function requireSignature() { + return (req, res, next) => { + const publicKey = req.headers["x-public-key"]; + const signature = req.headers["x-signature"]; + const timestamp = req.headers["x-timestamp"]; + if (!publicKey || !signature || !timestamp) { + 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) { + 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)) { + res.status(401).json({ error: "Invalid signature" }); + return; + } + getAccountByPublicKey(publicKey).then((account) => { + if (!account) { + res.status(401).json({ error: "Unknown account" }); + return; + } + req.account = account; + next(); + }).catch(next); + }; +} + +/** 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 worldZoos = zoos.map((z) => ({ + id: z.id, + name: z.name, + x: z.x, + y: z.y, + animalWeights: z.animal_weights, + game_state: z.game_state ?? null, + })); + res.json({ worldZoos, mapWidth: params.mapWidth, mapHeight: params.mapHeight }); + } catch (e) { + next(e); + } +}); + +/** GET /api/zoos/me — my zoo + game_state (auth). */ +router.get("/me", requireSignature(), (req, res, next) => { + getZooByAccountId(req.account.id) + .then((zoo) => { + if (!zoo) { + res.status(404).json({ error: "No zoo for this account" }); + return; + } + updateLastSeen(req.account.id); + res.json({ + zooId: zoo.id, + name: zoo.name, + x: zoo.x, + y: zoo.y, + game_state: zoo.game_state, + }); + }) + .catch(next); +}); + +/** PATCH /api/zoos/me — update game_state (auth). Body: { game_state }. */ +router.patch("/me", requireSignature(), (req, res, next) => { + getZooByAccountId(req.account.id) + .then((zoo) => { + if (!zoo) { + res.status(404).json({ error: "No zoo for this account" }); + return; + } + const game_state = req.body?.game_state; + if (game_state === null || game_state === undefined || typeof game_state !== "object") { + res.status(400).json({ error: "game_state object required" }); + return; + } + return updateZooGameState(zoo.id, game_state).then(() => { + res.json({ ok: true }); + }); + }) + .catch(next); +}); + +/** POST /api/zoos/me — create my zoo (auth). Body: { name?, game_state }. */ +router.post("/me", requireSignature(), (req, res, next) => { + getZooByAccountId(req.account.id) + .then((existing) => { + if (existing) { + res.status(409).json({ error: "Zoo already exists" }); + return; + } + const name = req.body?.name?.trim() || req.account.pseudo; + const game_state = req.body?.game_state; + if (game_state === null || game_state === undefined || typeof game_state !== "object") { + res.status(400).json({ error: "game_state object required" }); + return; + } + const x = 25 + Math.random() * 50; + const y = 25 + Math.random() * 50; + return createZoo({ accountId: req.account.id, name, x, y, gameState: game_state }).then(({ id }) => { + res.status(201).json({ zooId: id, name, x, y }); + }); + }) + .catch(next); +}); + +export default router; diff --git a/server/schema.sql b/server/schema.sql new file mode 100644 index 0000000..cab452f --- /dev/null +++ b/server/schema.sql @@ -0,0 +1,36 @@ +-- Build a Zoo web: accounts and zoos +-- Run once against your PostgreSQL (e.g. psql -f server/schema.sql) + +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + public_key TEXT UNIQUE NOT NULL, + pseudo TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS zoos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + x NUMERIC NOT NULL, + y NUMERIC NOT NULL, + is_bot BOOLEAN NOT NULL DEFAULT false, + animal_weights JSONB NOT NULL DEFAULT '{}', + game_state JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(account_id) -- only one zoo per account; bots have account_id NULL +); + +CREATE INDEX IF NOT EXISTS zoos_account_id ON zoos(account_id); +CREATE INDEX IF NOT EXISTS zoos_is_bot ON zoos(is_bot); + +-- One row: map dimensions and min zoos for density +CREATE TABLE IF NOT EXISTS map_config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL +); + +INSERT INTO map_config (key, value) VALUES + ('params', '{"mapWidth": 100, "mapHeight": 100, "minZoos": 5}') +ON CONFLICT (key) DO NOTHING; diff --git a/src/ReplicatedStorage/Shared/BiomeRules.lua b/src/ReplicatedStorage/Shared/BiomeRules.lua new file mode 100644 index 0000000..420ea37 --- /dev/null +++ b/src/ReplicatedStorage/Shared/BiomeRules.lua @@ -0,0 +1,18 @@ +local BiomeRules = {} + +function BiomeRules.GetCellBiome(width, x) + local third = math.max(1, math.floor(width / 3)) + if x <= third then + return "Meadow" + end + if x <= third * 2 then + return "Ocean" + end + return "Mountain" +end + +function BiomeRules.IsAnimalAllowedOnBiome(animalBiome, cellBiome) + return animalBiome == cellBiome +end + +return BiomeRules diff --git a/src/ReplicatedStorage/Shared/Economy.lua b/src/ReplicatedStorage/Shared/Economy.lua new file mode 100644 index 0000000..0c49bcb --- /dev/null +++ b/src/ReplicatedStorage/Shared/Economy.lua @@ -0,0 +1,38 @@ +local GameConfig = require(script.Parent.GameConfig) + +local Economy = {} + +function Economy.ExponentialCost(baseCost, growth, level) + local exponent = math.max(0, level - 1) + local raw = baseCost * (growth ^ exponent) + return math.floor(raw + 0.5) +end + +function Economy.GetConveyorUpgradeCost(currentLevel) + return Economy.ExponentialCost( + GameConfig.Conveyor.BaseUpgradeCost, + GameConfig.Conveyor.UpgradeGrowth, + currentLevel + ) +end + +function Economy.GetPlotUpgradeCost(currentLevel) + return Economy.ExponentialCost( + GameConfig.Plot.BaseUpgradeCost, + GameConfig.Plot.UpgradeGrowth, + currentLevel + ) +end + +function Economy.GetLevelMultiplier(level) + local current = level or 1 + return 1 + (0.1 * math.max(0, current - 1)) +end + +function Economy.GetSellValue(baseIncomePerSecond, level, mutationMultiplier, sellFactor) + local levelMultiplier = Economy.GetLevelMultiplier(level) + local value = baseIncomePerSecond * mutationMultiplier * levelMultiplier * sellFactor + return math.floor(value + 0.5) +end + +return Economy diff --git a/src/ReplicatedStorage/Shared/GameConfig.lua b/src/ReplicatedStorage/Shared/GameConfig.lua new file mode 100644 index 0000000..9e7c959 --- /dev/null +++ b/src/ReplicatedStorage/Shared/GameConfig.lua @@ -0,0 +1,47 @@ +local GameConfig = {} + +GameConfig.DataStoreName = "BuildAZooRecode_v1" +GameConfig.StateVersion = 1 + +GameConfig.IncomeTickSeconds = 1 +GameConfig.StateSyncSeconds = 1 + +GameConfig.Plot = { + BaseWidth = 6, + BaseHeight = 6, + MaxLevel = 8, + ExpandByLevel = 2, + BaseUpgradeCost = 300, + UpgradeGrowth = 1.7, +} + +GameConfig.Conveyor = { + MaxLevel = 8, + BaseUpgradeCost = 250, + UpgradeGrowth = 1.65, + OfferCount = 3, + RefreshSeconds = 4, +} + +GameConfig.Mutation = { + BaseChance = 0.06, +} + +GameConfig.Events = { + { + id = "weekend_income", + startUtc = 0, + endUtc = 0, + incomeMultiplier = 1, + mutationBonus = 0, + }, +} + +GameConfig.RateLimits = { + BuyEggPer10s = 20, + PlaceEggPer10s = 30, + UpgradePer10s = 10, + SellPer10s = 25, +} + +return GameConfig diff --git a/src/ReplicatedStorage/Shared/GridUtils.lua b/src/ReplicatedStorage/Shared/GridUtils.lua new file mode 100644 index 0000000..875b4c1 --- /dev/null +++ b/src/ReplicatedStorage/Shared/GridUtils.lua @@ -0,0 +1,19 @@ +local GameConfig = require(script.Parent.GameConfig) + +local GridUtils = {} + +function GridUtils.CellKey(x, y) + return string.format("%d_%d", x, y) +end + +function GridUtils.PlotSizeFromLevel(plotLevel) + local level = math.max(1, math.min(GameConfig.Plot.MaxLevel, plotLevel)) + local extra = (level - 1) * GameConfig.Plot.ExpandByLevel + return GameConfig.Plot.BaseWidth + extra, GameConfig.Plot.BaseHeight + extra +end + +function GridUtils.WithinBounds(width, height, x, y) + return x >= 1 and y >= 1 and x <= width and y <= height +end + +return GridUtils diff --git a/src/ReplicatedStorage/Shared/LootTables.lua b/src/ReplicatedStorage/Shared/LootTables.lua new file mode 100644 index 0000000..56a1724 --- /dev/null +++ b/src/ReplicatedStorage/Shared/LootTables.lua @@ -0,0 +1,48 @@ +local LootTables = {} + +LootTables.EggTypes = { + Basic = { + price = 50, + hatchSeconds = 20, + minConveyorLevel = 1, + loot = { + { id = "rabbit", weight = 60 }, + { id = "deer", weight = 30 }, + { id = "lion", weight = 10 }, + }, + }, + Ocean = { + price = 120, + hatchSeconds = 35, + minConveyorLevel = 2, + loot = { + { id = "seal", weight = 55 }, + { id = "dolphin", weight = 35 }, + { id = "shark", weight = 10 }, + }, + }, + Mountain = { + price = 220, + hatchSeconds = 45, + minConveyorLevel = 4, + loot = { + { id = "goat", weight = 55 }, + { id = "eagle", weight = 30 }, + { id = "snow_leopard", weight = 15 }, + }, + }, +} + +LootTables.Animals = { + rabbit = { baseIncomePerSecond = 0.8, rarity = "Common", biome = "Meadow", sellFactor = 20 }, + deer = { baseIncomePerSecond = 1.2, rarity = "Uncommon", biome = "Meadow", sellFactor = 24 }, + lion = { baseIncomePerSecond = 2.5, rarity = "Rare", biome = "Meadow", sellFactor = 30 }, + seal = { baseIncomePerSecond = 1.4, rarity = "Uncommon", biome = "Ocean", sellFactor = 24 }, + dolphin = { baseIncomePerSecond = 2.1, rarity = "Rare", biome = "Ocean", sellFactor = 30 }, + shark = { baseIncomePerSecond = 3.2, rarity = "Epic", biome = "Ocean", sellFactor = 40 }, + goat = { baseIncomePerSecond = 2.2, rarity = "Rare", biome = "Mountain", sellFactor = 30 }, + eagle = { baseIncomePerSecond = 3.1, rarity = "Epic", biome = "Mountain", sellFactor = 40 }, + snow_leopard = { baseIncomePerSecond = 4.2, rarity = "Legendary", biome = "Mountain", sellFactor = 52 }, +} + +return LootTables diff --git a/src/ReplicatedStorage/Shared/MutationRules.lua b/src/ReplicatedStorage/Shared/MutationRules.lua new file mode 100644 index 0000000..f2cc450 --- /dev/null +++ b/src/ReplicatedStorage/Shared/MutationRules.lua @@ -0,0 +1,28 @@ +local MutationRules = {} + +MutationRules.Definitions = { + none = { incomeMultiplier = 1.0, weight = 0 }, + golden = { incomeMultiplier = 1.5, weight = 50 }, + crystal = { incomeMultiplier = 1.8, weight = 30 }, + void = { incomeMultiplier = 2.2, weight = 20 }, +} + +function MutationRules:GetMutationEntries() + local entries = {} + for mutationId, def in pairs(self.Definitions) do + if mutationId ~= "none" then + table.insert(entries, { id = mutationId, weight = def.weight }) + end + end + return entries +end + +function MutationRules:GetIncomeMultiplier(mutationId) + local def = self.Definitions[mutationId] + if def == nil then + error("MutationRules:GetIncomeMultiplier unknown mutation") + end + return def.incomeMultiplier +end + +return MutationRules diff --git a/src/ReplicatedStorage/Shared/StateView.lua b/src/ReplicatedStorage/Shared/StateView.lua new file mode 100644 index 0000000..4ecb5c0 --- /dev/null +++ b/src/ReplicatedStorage/Shared/StateView.lua @@ -0,0 +1,37 @@ +local StateView = {} + +local function copyCell(cell) + local out = {} + for key, value in pairs(cell) do + out[key] = value + end + return out +end + +function StateView.ToClientState(state) + local gridCells = {} + for key, cell in pairs(state.grid.cells) do + gridCells[key] = copyCell(cell) + end + + local offers = {} + for _, offer in ipairs(state.conveyorOffers) do + table.insert(offers, { eggType = offer.eggType, price = offer.price }) + end + + return { + coins = state.coins, + conveyorLevel = state.conveyorLevel, + plotLevel = state.plotLevel, + grid = { + width = state.grid.width, + height = state.grid.height, + cells = gridCells, + }, + pendingEggTokens = #state.pendingEggTokens, + conveyorOffers = offers, + lastOfferRefreshAt = state.lastOfferRefreshAt, + } +end + +return StateView diff --git a/src/ReplicatedStorage/Shared/Texts.lua b/src/ReplicatedStorage/Shared/Texts.lua new file mode 100644 index 0000000..a41828f --- /dev/null +++ b/src/ReplicatedStorage/Shared/Texts.lua @@ -0,0 +1,27 @@ +local Texts = {} + +Texts.en = { + hudLoading = "Loading...", + placementHint = "Arrows: select cell | 1/2/3: place token", + conveyorHint = "Conveyor (animated offers): click egg to buy", + noOffer = "No offer", + buyFailed = "Buy failed: %s", + boughtToken = "Bought %s token=%d", + upgradeConveyorFailed = "Upgrade conveyor failed: %s", + upgradePlotFailed = "Upgrade plot failed: %s", + sellFailed = "Sell failed: %s", + errorPrefix = "Error: %s", + statusTemplate = "Coins: %.1f | Plot %d | Conv %d | Selected: %d,%d | Tokens: %d", +} + +Texts.defaultLocale = "en" + +function Texts.Get(locale) + local bucket = Texts[locale] + if bucket == nil then + error("Texts.Get: unsupported locale") + end + return bucket +end + +return Texts diff --git a/src/ReplicatedStorage/Shared/WeightedRandom.lua b/src/ReplicatedStorage/Shared/WeightedRandom.lua new file mode 100644 index 0000000..4a643aa --- /dev/null +++ b/src/ReplicatedStorage/Shared/WeightedRandom.lua @@ -0,0 +1,24 @@ +local WeightedRandom = {} + +function WeightedRandom.PickId(rng, entries) + local total = 0 + for _, entry in ipairs(entries) do + total += entry.weight + end + if total <= 0 then + error("WeightedRandom.PickId: non-positive total weight") + end + + local roll = rng:NextNumber(0, total) + local cumulative = 0 + for _, entry in ipairs(entries) do + cumulative += entry.weight + if roll < cumulative then + return entry.id + end + end + + return entries[#entries].id +end + +return WeightedRandom diff --git a/src/ServerScriptService/ServerMain.server.lua b/src/ServerScriptService/ServerMain.server.lua new file mode 100644 index 0000000..7ba32d5 --- /dev/null +++ b/src/ServerScriptService/ServerMain.server.lua @@ -0,0 +1,212 @@ +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local GameConfig = require(ReplicatedStorage.Shared.GameConfig) +local StateView = require(ReplicatedStorage.Shared.StateView) + +local Logger = require(script.Parent.Services.Logger) +local PlayerDataService = require(script.Parent.Services.PlayerDataService) +local ZooService = require(script.Parent.Services.ZooService) +local ConveyorService = require(script.Parent.Services.ConveyorService) +local IncomeService = require(script.Parent.Services.IncomeService) +local HatchingService = require(script.Parent.Services.HatchingService) +local EventService = require(script.Parent.Services.EventService) +local TradeService = require(script.Parent.Services.TradeService) +local RateLimiterService = require(script.Parent.Services.RateLimiterService) + +local rateLimiter = RateLimiterService.new() + +local function nowUnix() + return os.time() +end + +local function ensureRemotes() + local remotes = ReplicatedStorage:FindFirstChild("Remotes") + if remotes == nil then + remotes = Instance.new("Folder") + remotes.Name = "Remotes" + remotes.Parent = ReplicatedStorage + end + + local function ensureRemote(className, name) + local existing = remotes:FindFirstChild(name) + if existing ~= nil then + return existing + end + local remote = Instance.new(className) + remote.Name = name + remote.Parent = remotes + return remote + end + + return { + RequestBuyEgg = ensureRemote("RemoteFunction", "RequestBuyEgg"), + PlaceEgg = ensureRemote("RemoteEvent", "PlaceEgg"), + RequestUpgrade = ensureRemote("RemoteFunction", "RequestUpgrade"), + SellAnimal = ensureRemote("RemoteFunction", "SellAnimal"), + RequestState = ensureRemote("RemoteFunction", "RequestState"), + StateSync = ensureRemote("RemoteEvent", "StateSync"), + } +end + +local Remotes = ensureRemotes() + +local function syncState(player) + local state = PlayerDataService:Get(player) + if state == nil then + return + end + Remotes.StateSync:FireClient(player, StateView.ToClientState(state)) +end + +local function rejectForRateLimit(player, action) + Logger.Warn("ServerMain", "Rate limit reached", { userId = player.UserId, action = action }) + return false, "RateLimited" +end + +Remotes.RequestBuyEgg.OnServerInvoke = function(player, eggType) + local allowed = rateLimiter:Allow(player.UserId, "BuyEgg", GameConfig.RateLimits.BuyEggPer10s, 10) + if allowed == false then + return rejectForRateLimit(player, "BuyEgg") + end + + local state = PlayerDataService:Get(player) + if state == nil then + return false, "NoState" + end + + local ok, tokenOrReason = ZooService:TryBuyEgg(state, eggType) + if ok == false then + return false, tokenOrReason + end + + syncState(player) + return true, tokenOrReason +end + +Remotes.PlaceEgg.OnServerEvent:Connect(function(player, tokenId, x, y) + local allowed = rateLimiter:Allow(player.UserId, "PlaceEgg", GameConfig.RateLimits.PlaceEggPer10s, 10) + if allowed == false then + Remotes.StateSync:FireClient(player, { error = "RateLimited" }) + return + end + + local state = PlayerDataService:Get(player) + if state == nil then + Remotes.StateSync:FireClient(player, { error = "NoState" }) + return + end + + local ok, reason = ZooService:TryPlaceEgg(state, tokenId, x, y, nowUnix()) + if ok == false then + Remotes.StateSync:FireClient(player, { error = reason }) + return + end + + syncState(player) +end) + +Remotes.RequestUpgrade.OnServerInvoke = function(player, upgradeType) + local allowed = rateLimiter:Allow(player.UserId, "Upgrade", GameConfig.RateLimits.UpgradePer10s, 10) + if allowed == false then + return rejectForRateLimit(player, "Upgrade") + end + + local state = PlayerDataService:Get(player) + if state == nil then + return false, "NoState" + end + + local ok, reason + if upgradeType == "Conveyor" then + ok, reason = ConveyorService:TryUpgrade(state) + elseif upgradeType == "Plot" then + ok, reason = ZooService:TryUpgradePlot(state) + else + return false, "UnknownUpgrade" + end + + if ok == false then + return false, reason + end + + syncState(player) + return true, nil +end + +Remotes.SellAnimal.OnServerInvoke = function(player, x, y) + local allowed = rateLimiter:Allow(player.UserId, "Sell", GameConfig.RateLimits.SellPer10s, 10) + if allowed == false then + return rejectForRateLimit(player, "Sell") + end + + local state = PlayerDataService:Get(player) + if state == nil then + return false, "NoState" + end + + local ok, valueOrReason = TradeService:SellAnimalToNpc(state, x, y) + if ok == false then + return false, valueOrReason + end + + syncState(player) + return true, valueOrReason +end + +Remotes.RequestState.OnServerInvoke = function(player) + local state = PlayerDataService:Get(player) + if state == nil then + return false, "NoState" + end + return true, StateView.ToClientState(state) +end + +Players.PlayerAdded:Connect(function(player) + local ok, reason = PlayerDataService:Load(player) + if ok == false then + player:Kick(string.format("Unable to load profile: %s", reason)) + return + end + + local state = PlayerDataService:Get(player) + ConveyorService:RefreshOffers(state, nowUnix()) + syncState(player) +end) + +local accumulator = 0 +local syncAccumulator = 0 + +RunService.Heartbeat:Connect(function(dt) + accumulator += dt + syncAccumulator += dt + if accumulator < GameConfig.IncomeTickSeconds then + return + end + + local tickDt = accumulator + accumulator = 0 + local now = nowUnix() + local eventModifiers = EventService:GetActiveModifiers(now) + + for _, player in ipairs(Players:GetPlayers()) do + local state = PlayerDataService:Get(player) + if state ~= nil then + if ConveyorService:ShouldRefresh(state, now) then + ConveyorService:RefreshOffers(state, now) + end + + IncomeService:Tick(state, tickDt, eventModifiers) + HatchingService:Run(state, now, eventModifiers) + end + end + + if syncAccumulator >= GameConfig.StateSyncSeconds then + syncAccumulator = 0 + for _, player in ipairs(Players:GetPlayers()) do + syncState(player) + end + end +end) + diff --git a/src/ServerScriptService/Services/ConveyorService.lua b/src/ServerScriptService/Services/ConveyorService.lua new file mode 100644 index 0000000..7226084 --- /dev/null +++ b/src/ServerScriptService/Services/ConveyorService.lua @@ -0,0 +1,61 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local GameConfig = require(ReplicatedStorage.Shared.GameConfig) +local LootTables = require(ReplicatedStorage.Shared.LootTables) +local Economy = require(ReplicatedStorage.Shared.Economy) +local WeightedRandom = require(ReplicatedStorage.Shared.WeightedRandom) + +local ConveyorService = {} +ConveyorService.__index = ConveyorService + +local function getEligibleEggTypes(conveyorLevel) + local entries = {} + for eggType, def in pairs(LootTables.EggTypes) do + if conveyorLevel >= def.minConveyorLevel then + table.insert(entries, { id = eggType, weight = 100 - (def.minConveyorLevel * 8) }) + end + end + return entries +end + +function ConveyorService:RefreshOffers(state, nowUnix) + local rng = Random.new(nowUnix + state.conveyorLevel) + local pool = getEligibleEggTypes(state.conveyorLevel) + local offers = {} + for _ = 1, GameConfig.Conveyor.OfferCount do + local eggType = WeightedRandom.PickId(rng, pool) + local eggDef = LootTables.EggTypes[eggType] + table.insert(offers, { eggType = eggType, price = eggDef.price }) + end + state.conveyorOffers = offers + state.lastOfferRefreshAt = nowUnix +end + +function ConveyorService:ShouldRefresh(state, nowUnix) + return (nowUnix - state.lastOfferRefreshAt) >= GameConfig.Conveyor.RefreshSeconds +end + +function ConveyorService:TryUpgrade(state) + if state.conveyorLevel >= GameConfig.Conveyor.MaxLevel then + return false, "ConveyorMaxLevel" + end + local cost = Economy.GetConveyorUpgradeCost(state.conveyorLevel) + if state.coins < cost then + return false, "NotEnoughCoins" + end + + state.coins -= cost + state.conveyorLevel += 1 + return true, nil +end + +function ConveyorService:FindOffer(state, eggType) + for _, offer in ipairs(state.conveyorOffers) do + if offer.eggType == eggType then + return offer + end + end + return nil +end + +return ConveyorService diff --git a/src/ServerScriptService/Services/EventService.lua b/src/ServerScriptService/Services/EventService.lua new file mode 100644 index 0000000..ebd0ed8 --- /dev/null +++ b/src/ServerScriptService/Services/EventService.lua @@ -0,0 +1,25 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local GameConfig = require(ReplicatedStorage.Shared.GameConfig) + +local EventService = {} +EventService.__index = EventService + +function EventService:GetActiveModifiers(nowUnix) + local incomeMultiplier = 1 + local mutationBonus = 0 + + for _, eventDef in ipairs(GameConfig.Events) do + if eventDef.startUtc > 0 and nowUnix >= eventDef.startUtc and nowUnix <= eventDef.endUtc then + incomeMultiplier *= eventDef.incomeMultiplier + mutationBonus += eventDef.mutationBonus + end + end + + return { + incomeMultiplier = incomeMultiplier, + mutationBonus = mutationBonus, + } +end + +return EventService diff --git a/src/ServerScriptService/Services/HatchingService.lua b/src/ServerScriptService/Services/HatchingService.lua new file mode 100644 index 0000000..3152703 --- /dev/null +++ b/src/ServerScriptService/Services/HatchingService.lua @@ -0,0 +1,77 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local GameConfig = require(ReplicatedStorage.Shared.GameConfig) +local LootTables = require(ReplicatedStorage.Shared.LootTables) +local MutationRules = require(ReplicatedStorage.Shared.MutationRules) +local WeightedRandom = require(ReplicatedStorage.Shared.WeightedRandom) +local BiomeRules = require(ReplicatedStorage.Shared.BiomeRules) +local GridUtils = require(ReplicatedStorage.Shared.GridUtils) + +local HatchingService = {} +HatchingService.__index = HatchingService + +local function buildAnimalCell(animalId, mutationId, nowUnix) + return { + kind = "animal", + id = animalId, + mutation = mutationId, + level = 1, + placedAt = nowUnix, + } +end + +function HatchingService:TryHatchCell(state, x, y, nowUnix, eventModifiers) + local key = GridUtils.CellKey(x, y) + local cell = state.grid.cells[key] + if cell == nil or cell.kind ~= "egg" then + return false + end + if nowUnix < cell.hatchAt then + return false + end + + local eggDef = LootTables.EggTypes[cell.eggType] + if eggDef == nil then + error("HatchingService: unknown egg type in state") + end + + local rng = Random.new(cell.seed) + local pickedAnimalId = WeightedRandom.PickId(rng, eggDef.loot) + local animalDef = LootTables.Animals[pickedAnimalId] + if animalDef == nil then + error("HatchingService: unknown animal from loot table") + end + + local cellBiome = BiomeRules.GetCellBiome(state.grid.width, x) + if BiomeRules.IsAnimalAllowedOnBiome(animalDef.biome, cellBiome) == false then + state.grid.cells[key] = nil + return true + end + + local mutationChance = GameConfig.Mutation.BaseChance + eventModifiers.mutationBonus + local mutationId = "none" + if rng:NextNumber(0, 1) < mutationChance then + mutationId = WeightedRandom.PickId(rng, MutationRules:GetMutationEntries()) + end + + state.grid.cells[key] = buildAnimalCell(pickedAnimalId, mutationId, nowUnix) + return true +end + +function HatchingService:Run(state, nowUnix, eventModifiers) + local changed = false + for key, cell in pairs(state.grid.cells) do + if cell.kind == "egg" and nowUnix >= cell.hatchAt then + local xText, yText = string.match(key, "^(%d+)_(%d+)$") + if xText ~= nil and yText ~= nil then + local hatched = self:TryHatchCell(state, tonumber(xText), tonumber(yText), nowUnix, eventModifiers) + if hatched then + changed = true + end + end + end + end + return changed +end + +return HatchingService diff --git a/src/ServerScriptService/Services/IncomeService.lua b/src/ServerScriptService/Services/IncomeService.lua new file mode 100644 index 0000000..1a71657 --- /dev/null +++ b/src/ServerScriptService/Services/IncomeService.lua @@ -0,0 +1,33 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local LootTables = require(ReplicatedStorage.Shared.LootTables) +local MutationRules = require(ReplicatedStorage.Shared.MutationRules) +local Economy = require(ReplicatedStorage.Shared.Economy) + +local IncomeService = {} +IncomeService.__index = IncomeService + +local function incomePerSecond(cell) + local animalDef = LootTables.Animals[cell.id] + if animalDef == nil then + error("IncomeService: unknown animal in grid") + end + + local mutationMult = MutationRules:GetIncomeMultiplier(cell.mutation) + local levelMult = Economy.GetLevelMultiplier(cell.level) + return animalDef.baseIncomePerSecond * mutationMult * levelMult +end + +function IncomeService:Tick(state, dt, eventModifiers) + local total = 0 + for _, cell in pairs(state.grid.cells) do + if cell.kind == "animal" then + total += incomePerSecond(cell) * dt * eventModifiers.incomeMultiplier + end + end + + state.coins += total + return total +end + +return IncomeService diff --git a/src/ServerScriptService/Services/Logger.lua b/src/ServerScriptService/Services/Logger.lua new file mode 100644 index 0000000..62ec99d --- /dev/null +++ b/src/ServerScriptService/Services/Logger.lua @@ -0,0 +1,15 @@ +local Logger = {} + +function Logger.Info(context, message, payload) + print(string.format("[INFO][%s] %s", context, message), payload or "") +end + +function Logger.Warn(context, message, payload) + warn(string.format("[WARN][%s] %s", context, message), payload or "") +end + +function Logger.Error(context, message, payload) + warn(string.format("[ERROR][%s] %s", context, message), payload or "") +end + +return Logger diff --git a/src/ServerScriptService/Services/PlacementService.lua b/src/ServerScriptService/Services/PlacementService.lua new file mode 100644 index 0000000..110dc04 --- /dev/null +++ b/src/ServerScriptService/Services/PlacementService.lua @@ -0,0 +1,36 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local GridUtils = require(ReplicatedStorage.Shared.GridUtils) + +local PlacementService = {} +PlacementService.__index = PlacementService + +function PlacementService:CanPlace(state, x, y) + if GridUtils.WithinBounds(state.grid.width, state.grid.height, x, y) == false then + return false, "OutOfBounds" + end + local key = GridUtils.CellKey(x, y) + if state.grid.cells[key] ~= nil then + return false, "Occupied" + end + return true, nil +end + +function PlacementService:PlaceEgg(state, eggType, tokenId, x, y, hatchAt, seed) + local canPlace, reason = self:CanPlace(state, x, y) + if canPlace == false then + return false, reason + end + + local key = GridUtils.CellKey(x, y) + state.grid.cells[key] = { + kind = "egg", + eggType = eggType, + tokenId = tokenId, + hatchAt = hatchAt, + seed = seed, + } + return true, nil +end + +return PlacementService diff --git a/src/ServerScriptService/Services/PlayerDataService.lua b/src/ServerScriptService/Services/PlayerDataService.lua new file mode 100644 index 0000000..7b048c3 --- /dev/null +++ b/src/ServerScriptService/Services/PlayerDataService.lua @@ -0,0 +1,89 @@ +local DataStoreService = game:GetService("DataStoreService") +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local GameConfig = require(ReplicatedStorage.Shared.GameConfig) +local GridUtils = require(ReplicatedStorage.Shared.GridUtils) +local Logger = require(script.Parent.Logger) + +local PlayerDataService = {} +PlayerDataService.__index = PlayerDataService + +local store = DataStoreService:GetDataStore(GameConfig.DataStoreName) +local session = {} + +local function buildDefaultState() + local width, height = GridUtils.PlotSizeFromLevel(1) + return { + version = GameConfig.StateVersion, + coins = 200, + conveyorLevel = 1, + plotLevel = 1, + grid = { width = width, height = height, cells = {} }, + pendingEggTokens = {}, + nextTokenId = 1, + conveyorOffers = {}, + lastOfferRefreshAt = 0, + } +end + +function PlayerDataService:Get(player) + return session[player.UserId] +end + +function PlayerDataService:Load(player) + local key = tostring(player.UserId) + local ok, result = pcall(function() + return store:GetAsync(key) + end) + + if not ok then + Logger.Error("PlayerDataService", "DataStore load failed", { userId = player.UserId }) + return false, "DataStoreLoadFailed" + end + + if result == nil then + session[player.UserId] = buildDefaultState() + return true, nil + end + + if type(result) ~= "table" then + Logger.Error("PlayerDataService", "Invalid saved data format", { userId = player.UserId }) + return false, "InvalidSavedState" + end + + session[player.UserId] = result + return true, nil +end + +function PlayerDataService:Save(player) + local state = session[player.UserId] + if state == nil then + return true, nil + end + + local key = tostring(player.UserId) + local ok = pcall(function() + store:SetAsync(key, state) + end) + + if not ok then + Logger.Error("PlayerDataService", "DataStore save failed", { userId = player.UserId }) + return false, "DataStoreSaveFailed" + end + + return true, nil +end + +Players.PlayerRemoving:Connect(function(player) + PlayerDataService:Save(player) + session[player.UserId] = nil +end) + +game:BindToClose(function() + for _, player in ipairs(Players:GetPlayers()) do + PlayerDataService:Save(player) + end +end) + +return PlayerDataService diff --git a/src/ServerScriptService/Services/RateLimiterService.lua b/src/ServerScriptService/Services/RateLimiterService.lua new file mode 100644 index 0000000..91783a0 --- /dev/null +++ b/src/ServerScriptService/Services/RateLimiterService.lua @@ -0,0 +1,36 @@ +local RateLimiterService = {} +RateLimiterService.__index = RateLimiterService + +function RateLimiterService.new() + local self = setmetatable({}, RateLimiterService) + self.counters = {} + return self +end + +function RateLimiterService:Allow(userId, action, maxCalls, windowSeconds) + local userCounters = self.counters[userId] + if userCounters == nil then + userCounters = {} + self.counters[userId] = userCounters + end + + local now = os.clock() + local bucket = userCounters[action] + if bucket == nil or (now - bucket.windowStart) > windowSeconds then + userCounters[action] = { count = 1, windowStart = now } + return true + end + + if bucket.count >= maxCalls then + return false + end + + bucket.count += 1 + return true +end + +function RateLimiterService:ClearUser(userId) + self.counters[userId] = nil +end + +return RateLimiterService diff --git a/src/ServerScriptService/Services/TradeService.lua b/src/ServerScriptService/Services/TradeService.lua new file mode 100644 index 0000000..0443e60 --- /dev/null +++ b/src/ServerScriptService/Services/TradeService.lua @@ -0,0 +1,36 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local LootTables = require(ReplicatedStorage.Shared.LootTables) +local MutationRules = require(ReplicatedStorage.Shared.MutationRules) +local Economy = require(ReplicatedStorage.Shared.Economy) +local GridUtils = require(ReplicatedStorage.Shared.GridUtils) + +local TradeService = {} +TradeService.__index = TradeService + +function TradeService:SellAnimalToNpc(state, x, y) + local key = GridUtils.CellKey(x, y) + local cell = state.grid.cells[key] + if cell == nil or cell.kind ~= "animal" then + return false, "NoAnimal" + end + + local animalDef = LootTables.Animals[cell.id] + if animalDef == nil then + error("TradeService: unknown animal") + end + + local mutationMultiplier = MutationRules:GetIncomeMultiplier(cell.mutation) + local sellValue = Economy.GetSellValue( + animalDef.baseIncomePerSecond, + cell.level, + mutationMultiplier, + animalDef.sellFactor + ) + + state.grid.cells[key] = nil + state.coins += sellValue + return true, sellValue +end + +return TradeService diff --git a/src/ServerScriptService/Services/ZooService.lua b/src/ServerScriptService/Services/ZooService.lua new file mode 100644 index 0000000..7a90a89 --- /dev/null +++ b/src/ServerScriptService/Services/ZooService.lua @@ -0,0 +1,88 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local GameConfig = require(ReplicatedStorage.Shared.GameConfig) +local LootTables = require(ReplicatedStorage.Shared.LootTables) +local GridUtils = require(ReplicatedStorage.Shared.GridUtils) +local Economy = require(ReplicatedStorage.Shared.Economy) + +local PlacementService = require(script.Parent.PlacementService) +local ConveyorService = require(script.Parent.ConveyorService) + +local ZooService = {} +ZooService.__index = ZooService + +local function consumeToken(state, tokenId) + for index, token in ipairs(state.pendingEggTokens) do + if token.tokenId == tokenId then + table.remove(state.pendingEggTokens, index) + return token + end + end + return nil +end + +function ZooService:TryBuyEgg(state, eggType) + local offer = ConveyorService:FindOffer(state, eggType) + if offer == nil then + return false, "OfferUnavailable" + end + if state.coins < offer.price then + return false, "NotEnoughCoins" + end + if LootTables.EggTypes[eggType] == nil then + return false, "UnknownEgg" + end + + state.coins -= offer.price + local token = { tokenId = state.nextTokenId, eggType = eggType, boughtAt = os.time() } + state.nextTokenId += 1 + table.insert(state.pendingEggTokens, token) + return true, token +end + +function ZooService:TryPlaceEgg(state, tokenId, x, y, nowUnix) + local token = consumeToken(state, tokenId) + if token == nil then + return false, "InvalidToken" + end + + local eggDef = LootTables.EggTypes[token.eggType] + if eggDef == nil then + error("ZooService: token contains unknown egg type") + end + + local ok, reason = PlacementService:PlaceEgg( + state, + token.eggType, + tokenId, + x, + y, + nowUnix + eggDef.hatchSeconds, + math.random(1, 2000000000) + ) + if ok == false then + table.insert(state.pendingEggTokens, token) + return false, reason + end + + return true, nil +end + +function ZooService:TryUpgradePlot(state) + if state.plotLevel >= GameConfig.Plot.MaxLevel then + return false, "PlotMaxLevel" + end + local cost = Economy.GetPlotUpgradeCost(state.plotLevel) + if state.coins < cost then + return false, "NotEnoughCoins" + end + + state.coins -= cost + state.plotLevel += 1 + local width, height = GridUtils.PlotSizeFromLevel(state.plotLevel) + state.grid.width = width + state.grid.height = height + return true, nil +end + +return ZooService diff --git a/src/StarterPlayer/StarterPlayerScripts/PlacementController.client.lua b/src/StarterPlayer/StarterPlayerScripts/PlacementController.client.lua new file mode 100644 index 0000000..c465e8e --- /dev/null +++ b/src/StarterPlayer/StarterPlayerScripts/PlacementController.client.lua @@ -0,0 +1,86 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Workspace = game:GetService("Workspace") + +local remotes = ReplicatedStorage:WaitForChild("Remotes") +local stateSync = remotes:WaitForChild("StateSync") :: RemoteEvent + +local root = Workspace:FindFirstChild("BuildAZooClientView") +if root == nil then + root = Instance.new("Folder") + root.Name = "BuildAZooClientView" + root.Parent = Workspace +end + +local gridFolder = Instance.new("Folder") +gridFolder.Name = "Grid" +gridFolder.Parent = root + +local cellsFolder = Instance.new("Folder") +cellsFolder.Name = "Cells" +cellsFolder.Parent = root + +local function clearFolder(folder) + for _, child in ipairs(folder:GetChildren()) do + child:Destroy() + end +end + +local function makeTile(x, y) + local part = Instance.new("Part") + part.Name = string.format("Tile_%d_%d", x, y) + part.Size = Vector3.new(4, 0.2, 4) + part.Anchored = true + part.Position = Vector3.new(x * 4, 0, y * 4) + part.Material = Enum.Material.SmoothPlastic + part.Color = Color3.fromRGB(80, 120, 80) + part.Parent = gridFolder +end + +local function makeCellPart(key, cell) + local xText, yText = string.match(key, "^(%d+)_(%d+)$") + if xText == nil or yText == nil then + return + end + local x = tonumber(xText) + local y = tonumber(yText) + + local part = Instance.new("Part") + part.Name = "Cell_" .. key + part.Anchored = true + part.Size = Vector3.new(3.2, 2.5, 3.2) + part.Position = Vector3.new(x * 4, 1.3, y * 4) + if cell.kind == "egg" then + part.Shape = Enum.PartType.Ball + part.Color = Color3.fromRGB(240, 230, 120) + else + part.Shape = Enum.PartType.Block + part.Color = Color3.fromRGB(130, 180, 240) + end + part.Parent = cellsFolder +end + +local function renderState(payload) + if payload.grid == nil then + return + end + + clearFolder(gridFolder) + clearFolder(cellsFolder) + + for x = 1, payload.grid.width do + for y = 1, payload.grid.height do + makeTile(x, y) + end + end + + for key, cell in pairs(payload.grid.cells) do + makeCellPart(key, cell) + end +end + +stateSync.OnClientEvent:Connect(function(payload) + if payload.error then + return + end + renderState(payload) +end) diff --git a/src/StarterPlayer/StarterPlayerScripts/UIController.client.lua b/src/StarterPlayer/StarterPlayerScripts/UIController.client.lua new file mode 100644 index 0000000..6eed870 --- /dev/null +++ b/src/StarterPlayer/StarterPlayerScripts/UIController.client.lua @@ -0,0 +1,200 @@ +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") + +local player = Players.LocalPlayer +local playerGui = player:WaitForChild("PlayerGui") +local remotes = ReplicatedStorage:WaitForChild("Remotes") +local Texts = require(ReplicatedStorage.Shared.Texts) +local t = Texts.Get(Texts.defaultLocale) + +local requestState = remotes:WaitForChild("RequestState") :: RemoteFunction +local requestBuyEgg = remotes:WaitForChild("RequestBuyEgg") :: RemoteFunction +local requestUpgrade = remotes:WaitForChild("RequestUpgrade") :: RemoteFunction +local sellAnimal = remotes:WaitForChild("SellAnimal") :: RemoteFunction +local placeEggRemote = remotes:WaitForChild("PlaceEgg") :: RemoteEvent +local stateSync = remotes:WaitForChild("StateSync") :: RemoteEvent + +local screenGui = Instance.new("ScreenGui") +screenGui.Name = "BuildAZooHUD" +screenGui.ResetOnSpawn = false +screenGui.Parent = playerGui + +local panel = Instance.new("Frame") +panel.Name = "Panel" +panel.Size = UDim2.new(0, 360, 0, 360) +panel.Position = UDim2.new(0, 10, 0, 10) +panel.Parent = screenGui + +local function mkLabel(y, h, text) + local label = Instance.new("TextLabel") + label.Size = UDim2.new(1, -10, 0, h) + label.Position = UDim2.new(0, 5, 0, y) + label.TextXAlignment = Enum.TextXAlignment.Left + label.Text = text + label.Parent = panel + return label +end + +local status = mkLabel(5, 30, t.hudLoading) +local gridHint = mkLabel(40, 26, t.placementHint) +local conveyorHint = mkLabel(68, 22, t.conveyorHint) + +local conveyorFrame = Instance.new("Frame") +conveyorFrame.Size = UDim2.new(1, -10, 0, 72) +conveyorFrame.Position = UDim2.new(0, 5, 0, 95) +conveyorFrame.Parent = panel + +local offerButtons = {} +for index = 1, 3 do + local btn = Instance.new("TextButton") + btn.Name = "Offer" .. tostring(index) + btn.Size = UDim2.new(0, 110, 0, 62) + btn.Position = UDim2.new(0, (index - 1) * 120, 0, 5) + btn.TextWrapped = true + btn.Parent = conveyorFrame + offerButtons[index] = btn +end + +local function button(name, y, label) + local b = Instance.new("TextButton") + b.Name = name + b.Size = UDim2.new(1, -10, 0, 28) + b.Position = UDim2.new(0, 5, 0, y) + b.Text = label + b.Parent = panel + return b +end + +local upConveyor = button("UpgradeConveyor", 172, "Upgrade Conveyor") +local upPlot = button("UpgradePlot", 204, "Upgrade Plot") +local sellAt = button("SellAt", 236, "Sell animal at selected cell") + +local selectedCellX = 1 +local selectedCellY = 1 +local pendingTokenByEggType = {} +local currentState = {} +local conveyorOffset = 0 + +local function clampSelection() + local width = currentState.grid and currentState.grid.width or 1 + local height = currentState.grid and currentState.grid.height or 1 + selectedCellX = math.clamp(selectedCellX, 1, width) + selectedCellY = math.clamp(selectedCellY, 1, height) +end + +local function updateStatus(localState) + status.Text = string.format( + t.statusTemplate, + localState.coins or 0, + localState.plotLevel or 1, + localState.conveyorLevel or 1, + selectedCellX, + selectedCellY, + localState.pendingEggTokens or 0 + ) +end + +local function buyEgg(eggType) + local buyOk, tokenOrReason = requestBuyEgg:InvokeServer(eggType) + if buyOk == false then + status.Text = string.format(t.buyFailed, tostring(tokenOrReason)) + return + end + pendingTokenByEggType[eggType] = tokenOrReason.tokenId + status.Text = string.format(t.boughtToken, eggType, tokenOrReason.tokenId) +end + +local function renderOffers(state) + for index, buttonInstance in ipairs(offerButtons) do + local offer = state.conveyorOffers and state.conveyorOffers[index] + if offer == nil then + buttonInstance.Text = t.noOffer + buttonInstance.AutoButtonColor = false + buttonInstance.Active = false + else + buttonInstance.Text = string.format("%s\n%d coins", offer.eggType, offer.price) + buttonInstance.AutoButtonColor = true + buttonInstance.Active = true + buttonInstance.MouseButton1Click:Once(function() + buyEgg(offer.eggType) + end) + end + end +end + +stateSync.OnClientEvent:Connect(function(payload) + if payload.error then + status.Text = string.format(t.errorPrefix, tostring(payload.error)) + return + end + currentState = payload + clampSelection() + renderOffers(payload) + updateStatus(payload) +end) + +local ok, stateOrReason = requestState:InvokeServer() +if ok then + currentState = stateOrReason + clampSelection() + renderOffers(currentState) + updateStatus(currentState) +end + +upConveyor.MouseButton1Click:Connect(function() + local success, reason = requestUpgrade:InvokeServer("Conveyor") + if success == false then + status.Text = string.format(t.upgradeConveyorFailed, tostring(reason)) + end +end) + +upPlot.MouseButton1Click:Connect(function() + local success, reason = requestUpgrade:InvokeServer("Plot") + if success == false then + status.Text = string.format(t.upgradePlotFailed, tostring(reason)) + end +end) + +sellAt.MouseButton1Click:Connect(function() + local success, reason = sellAnimal:InvokeServer(selectedCellX, selectedCellY) + if success == false then + status.Text = string.format(t.sellFailed, tostring(reason)) + end +end) + +UserInputService.InputBegan:Connect(function(input, gameProcessed) + if gameProcessed then + return + end + if input.KeyCode == Enum.KeyCode.One and pendingTokenByEggType.Basic ~= nil then + placeEggRemote:FireServer(pendingTokenByEggType.Basic, selectedCellX, selectedCellY) + pendingTokenByEggType.Basic = nil + elseif input.KeyCode == Enum.KeyCode.Two and pendingTokenByEggType.Ocean ~= nil then + placeEggRemote:FireServer(pendingTokenByEggType.Ocean, selectedCellX, selectedCellY) + pendingTokenByEggType.Ocean = nil + elseif input.KeyCode == Enum.KeyCode.Three and pendingTokenByEggType.Mountain ~= nil then + placeEggRemote:FireServer(pendingTokenByEggType.Mountain, selectedCellX, selectedCellY) + pendingTokenByEggType.Mountain = nil + elseif input.KeyCode == Enum.KeyCode.Right then + selectedCellX += 1 + elseif input.KeyCode == Enum.KeyCode.Left then + selectedCellX -= 1 + elseif input.KeyCode == Enum.KeyCode.Up then + selectedCellY -= 1 + elseif input.KeyCode == Enum.KeyCode.Down then + selectedCellY += 1 + end + clampSelection() + updateStatus(currentState) +end) + +RunService.RenderStepped:Connect(function(dt) + conveyorOffset += dt * 32 + local loopWidth = 360 + for index, buttonInstance in ipairs(offerButtons) do + local x = ((index - 1) * 120 - conveyorOffset) % loopWidth + buttonInstance.Position = UDim2.new(0, x, 0, 5) + end +end) diff --git a/web/css/main.css b/web/css/main.css new file mode 100644 index 0000000..6a7d2f6 --- /dev/null +++ b/web/css/main.css @@ -0,0 +1,1610 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, "Segoe UI", sans-serif; + background: linear-gradient(160deg, #0f1419 0%, #1a2332 50%, #0d1117 100%); + color: #e6edf3; + min-height: 100vh; + transition: background 0.5s ease; +} + +body.bg-phase-dawn.bg-weather-sun { background: linear-gradient(160deg, #2c1810 0%, #4a2c1a 50%, #1a2332 100%); } +body.bg-phase-day.bg-weather-sun { background: linear-gradient(160deg, #0f1419 0%, #1a2332 50%, #0d1117 100%); } +body.bg-phase-dusk.bg-weather-sun { background: linear-gradient(160deg, #2a1a24 0%, #1a1520 50%, #0d1117 100%); } +body.bg-phase-night.bg-weather-sun { background: linear-gradient(160deg, #0a0e14 0%, #0d1117 50%, #050810 100%); } +body.bg-phase-dawn.bg-weather-cloudy { background: linear-gradient(160deg, #2a2620 0%, #3d3830 50%, #1e2228 100%); } +body.bg-phase-day.bg-weather-cloudy { background: linear-gradient(160deg, #1e2228 0%, #252a32 50%, #161b22 100%); } +body.bg-phase-dusk.bg-weather-cloudy { background: linear-gradient(160deg, #1a1818 0%, #1e2228 50%, #0d1117 100%); } +body.bg-phase-night.bg-weather-cloudy { background: linear-gradient(160deg, #0c0e12 0%, #0d1117 50%, #080a0d 100%); } +body.bg-phase-dawn.bg-weather-rain { background: linear-gradient(160deg, #1a2028 0%, #252d38 50%, #161b22 100%); } +body.bg-phase-day.bg-weather-rain { background: linear-gradient(160deg, #161c24 0%, #1c232d 50%, #0d1117 100%); } +body.bg-phase-dusk.bg-weather-rain { background: linear-gradient(160deg, #14181e 0%, #181c24 50%, #0a0e12 100%); } +body.bg-phase-night.bg-weather-rain { background: linear-gradient(160deg, #080a0e 0%, #0d1117 50%, #050708 100%); } + +.boot-panel { + max-width: 360px; + margin: 40px auto; + padding: 24px; + background: linear-gradient(180deg, #21262d 0%, #161b22 100%); + border-radius: 12px; + border: 1px solid #30363d; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} +.boot-panel h1 { margin: 0 0 16px 0; font-size: 1.25rem; color: #58a6ff; } +.boot-panel p { margin: 0 0 12px 0; font-size: 0.9rem; color: #8b949e; } +.boot-panel input { width: 100%; padding: 10px 12px; margin-bottom: 12px; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; font-size: 1rem; } +.boot-panel button { padding: 10px 20px; border-radius: 8px; border: 1px solid #388bfd; background: #238636; color: #fff; cursor: pointer; font-size: 0.95rem; } +.boot-panel button:hover { background: #2ea043; } +.boot-err { color: #f85149; font-size: 0.85rem; margin-top: 8px; } + +#root { + display: flex; + flex-wrap: wrap; + gap: 20px; + padding: 20px; + align-items: flex-start; +} + +.game-bar { + flex: 0 0 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 16px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.game-bar-title-wrap { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.game-bar-title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #58a6ff; +} + +.game-bar-title-wrap .help-wrap { + display: inline-flex; +} + +.game-bar-title-wrap .tooltip-bubble.below { + left: 0; + top: 100%; + margin-top: 6px; +} + +.game-bar .status-bar { + flex: 1 1 auto; + margin-bottom: 0; + padding: 0; + background: transparent; + border: none; + box-shadow: none; +} + +.game-bar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + position: relative; +} + +.auto-profile-picker-wrap { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + padding: 10px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 100; + min-width: 200px; +} + +.auto-profile-picker-title { + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 8px; + color: #e6edf3; +} + +.auto-profile-picker-step { + font-size: 0.75rem; + color: #8b949e; + margin-bottom: 6px; +} + +.auto-profile-picker-families, +.auto-profile-picker-specialisations { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.auto-profile-picker-family-btn, +.auto-profile-picker-spec-btn { + padding: 4px 10px; + font-size: 0.75rem; + border-radius: 4px; + border: 1px solid #30363d; + background: #21262d; + color: #e6edf3; + cursor: pointer; +} + +.auto-profile-picker-family-btn:hover, +.auto-profile-picker-spec-btn:hover { + background: #30363d; +} + +.auto-profile-picker-cancel { + padding: 4px 10px; + font-size: 0.75rem; + border-radius: 4px; + border: 1px solid #30363d; + background: transparent; + color: #8b949e; + cursor: pointer; +} + +.auto-profile-picker-cancel:hover { + color: #e6edf3; +} + +.game-bar-view-switcher { + display: flex; + align-items: center; + gap: 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid #30363d; +} + +.game-bar-view-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: none; + border-radius: 0; + background: linear-gradient(180deg, #21262d 0%, #161b22 100%); + color: #8b949e; + font-size: 1.2rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.game-bar-view-btn:hover { + background: #30363d; + color: #e6edf3; +} + +.game-bar-view-btn.active { + background: #238636; + color: #fff; +} + +.game-bar-view-btn + .game-bar-view-btn { + border-left: 1px solid #30363d; +} + +.game-bar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: 8px; + border: 1px solid #30363d; + background: linear-gradient(180deg, #21262d 0%, #161b22 100%); + color: #e6edf3; + font-size: 1.2rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.game-bar-btn:hover:not(:disabled) { + background: #30363d; + border-color: #58a6ff; +} + +.game-bar-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.game-bar-btn-prestige { + border-color: #da3633; + background: linear-gradient(180deg, #3d1f1f 0%, #2a1515 100%); + color: #f85149; +} + +.game-bar-btn-prestige:hover:not(:disabled) { + background: #4d2525; + border-color: #f85149; +} + +.game-bar-btn-music.muted { + opacity: 0.45; + filter: grayscale(1); +} + +.game-bar-quest-wrap { + position: relative; +} + +.quest-dropdown { + display: none; + position: absolute; + right: 0; + top: 100%; + margin-top: 6px; + min-width: 260px; + max-width: 320px; + max-height: 280px; + overflow-y: auto; + padding: 10px 12px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 100; +} + +.game-bar-quest-wrap.open .quest-dropdown { + display: block; +} + +.quest-dropdown-title { + font-size: 0.85rem; + font-weight: 600; + color: #58a6ff; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid #21262d; +} + +.quest-dropdown .quest-list { + margin-top: 0; +} + +.panel { + background: linear-gradient(180deg, #21262d 0%, #161b22 100%); + border-radius: 12px; + padding: 16px; + min-width: 300px; + border: 1px solid #30363d; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.panel h2 { + margin: 0 0 12px 0; + font-size: 1.15rem; + font-weight: 600; + color: #58a6ff; +} + +.status { + font-size: 0.9rem; + margin-bottom: 10px; + word-break: break-word; + padding: 8px 10px; + background: #0d1117; + border-radius: 8px; + border: 1px solid #21262d; +} + +.music-toggle-wrap { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 0.9rem; +} + +.music-toggle-wrap input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: #58a6ff; +} + +.music-toggle-wrap label { + cursor: pointer; + user-select: none; +} + +.hint { + font-size: 0.85rem; + color: #8b949e; + margin-bottom: 12px; + padding: 8px 10px; + background: rgba(88, 166, 255, 0.08); + border-radius: 8px; + border-left: 3px solid #58a6ff; +} + +.conveyor-strip { + display: flex; + overflow: hidden; + padding: 12px 0; + min-height: 90px; + border: 1px solid #30363d; + border-radius: 10px; + margin-bottom: 14px; + background: linear-gradient(90deg, #0d1117 0%, #161b22 50%, #0d1117 100%); + background-size: 200% 100%; + animation: conveyor-bg 3s linear infinite; + position: relative; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3), 0 0 20px rgba(56, 139, 253, 0.15); +} + +.conveyor-title { + position: absolute; + top: 4px; + left: 12px; + font-size: 0.75rem; + color: #8b949e; + z-index: 1; + pointer-events: none; +} + +@keyframes conveyor-bg { + 0% { background-position: 0% 0; } + 100% { background-position: 200% 0; } +} + +.conveyor-strip .conveyor-track { + display: flex; + gap: 16px; + animation: conveyor-scroll 14s linear infinite; +} + +@keyframes conveyor-scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +.offer-btn { + flex-shrink: 0; + width: 110px; + padding: 12px; + border-radius: 10px; + border: 1px solid #388bfd; + background: linear-gradient(180deg, #238636 0%, #196c2e 100%); + color: #fff; + cursor: grab; + font-size: 0.85rem; + text-align: center; + white-space: normal; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: transform 0.15s, box-shadow 0.15s; + user-select: none; + -webkit-user-drag: element; +} + +.offer-btn:active { + cursor: grabbing; +} + +.offer-btn.dragging { + opacity: 0.7; + cursor: grabbing; +} + +.offer-btn:hover:not(.dragging) { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(56, 139, 253, 0.35); +} + +.offer-btn .offer-emoji { + display: block; + font-size: 1.8rem; + margin-bottom: 4px; +} + +.offer-btn .offer-price { + font-weight: 600; + color: #ffd700; +} + +.status-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + padding: 10px 12px; + margin-bottom: 12px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 10px; + font-size: 0.9rem; +} + +.status-bar-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.status-bar-icon { + font-size: 1.15rem; + line-height: 1; +} + +.status-bar-value { + font-weight: 600; + color: #e6edf3; +} + +.status-bar-time-weather { + margin-left: auto; +} + +.status-bar-time-weather.weather-rain .status-bar-value { color: #79c0ff; } +.status-bar-time-weather.weather-cloudy .status-bar-value { color: #8b949e; } + +.world-map-wrap { + position: relative; + min-height: 180px; + border: 1px solid #30363d; + border-radius: 10px; + margin-bottom: 14px; + background: linear-gradient(180deg, #0d1117 0%, #161b22 100%); + overflow: hidden; +} + +.world-map-title { + font-size: 0.8rem; + color: #8b949e; + padding: 6px 10px; + border-bottom: 1px solid #21262d; +} + +.world-map { + position: relative; + width: 100%; + height: 160px; + background: radial-gradient(ellipse at 50% 50%, #1a2332 0%, #0d1117 70%); +} + +.world-map-panel { + display: flex; + flex-direction: column; +} + +.world-map-wrap-square { + overflow: hidden; + padding: 12px; + border: 1px solid #30363d; + border-radius: 10px; + background: #0d1117; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3); + position: relative; + min-width: 320px; + min-height: 320px; + width: 320px; + height: 320px; + flex-shrink: 0; +} + +.world-map-wrap-square .world-map { + position: absolute; + inset: 12px; + width: calc(100% - 24px); + height: calc(100% - 24px); + background: radial-gradient(ellipse at 50% 50%, #1a2332 0%, #0d1117 70%); +} + +.world-map-biomes { + background: #0d1117; +} + +.world-map-cells { + position: absolute; + inset: 0; + display: grid; + z-index: 0; + pointer-events: none; +} + +.world-map-cell { + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.world-map-cell-meadow { + background: linear-gradient(145deg, #2d4a2a 0%, #1e3320 100%); +} + +.world-map-cell-freshwater { + background: linear-gradient(145deg, #1e4d5f 0%, #153a48 100%); +} + +.world-map-cell-ocean { + background: linear-gradient(145deg, #1e3a5f 0%, #152a48 100%); +} + +.world-map-cell-forest { + background: linear-gradient(145deg, #1e3d1e 0%, #152a15 100%); +} + +.world-map-cell-mountain { + background: linear-gradient(145deg, #4a4a4a 0%, #2d2d2d 100%); +} + +.tabs-wrap { + flex: 1 1 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.tabs-wrap > .error-msg { + margin-bottom: 8px; + flex-shrink: 0; +} + +.tabs-list { + display: flex; + gap: 0; + padding: 0 4px; + background: #161b22; + border-radius: 10px 10px 0 0; +} + +.tab-btn { + padding: 10px 18px; + font-size: 0.95rem; + font-weight: 600; + color: #8b949e; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.tab-btn:hover { + color: #e6edf3; +} + +.tab-btn.active { + color: #58a6ff; + border-bottom-color: #58a6ff; +} + +.tabs-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #21262d 0%, #161b22 100%); + border: 1px solid #30363d; + border-top: none; + border-radius: 0 0 10px 10px; + padding: 12px; +} + +.tab-panel { + display: none; + flex: 1 1 auto; + min-height: 320px; + flex-direction: column; + overflow: auto; +} + +.tab-panel.active { + display: flex; +} + +#tab-panel-world { + position: relative; +} + +.world-map-actions { + position: absolute; + right: 12px; + bottom: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + z-index: 2; +} + +.world-map-upgrade-zone { + width: 56px; + height: 56px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 2px dashed #2d4a6f; + background: rgba(30, 58, 95, 0.95); + color: #e6edf3; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.world-map-upgrade-zone:hover, +.world-map-upgrade-zone.can-upgrade { + border-color: #58a6ff; + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.3); +} + +.world-map-upgrade-zone-icon { + font-size: 1.5rem; + line-height: 1; +} + +.world-map-upgrade-zone-label { + font-size: 0.6rem; + margin-top: 2px; +} + +.world-map-upgrade-zone-arrow { + display: none; + font-size: 0.7rem; + margin-top: 2px; + color: #58a6ff; +} + +.world-map-upgrade-zone.can-upgrade .world-map-upgrade-zone-arrow { + display: block; +} + +.world-map-upgrade-zone-cost { + font-size: 0.55rem; + color: #8b949e; + margin-top: 1px; +} + +.world-map-counters { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + font-size: 0.65rem; + color: #8b949e; + align-items: center; +} + +.world-map-counter { + white-space: nowrap; +} + +.world-map-truck-drop-zone { + width: 56px; + height: 56px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 2px dashed #8b949e; + background: rgba(33, 38, 45, 0.95); + color: #8b949e; + cursor: default; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.world-map-truck-drop-zone:hover, +.world-map-truck-drop-zone.dragover { + border-color: #58a6ff; + background: rgba(88, 166, 255, 0.15); + color: #e6edf3; +} + +.world-map-truck-drop-icon { + font-size: 1.5rem; + line-height: 1; +} + +.world-map-truck-drop-label { + font-size: 0.6rem; + margin-top: 2px; +} + +.tab-panel-title { + margin: 0 0 8px 0; + font-size: 1rem; + font-weight: 600; + color: #58a6ff; +} + +.tab-panel .section-with-help { + margin-bottom: 8px; +} + +.world-map-sales-panel { + padding: 8px 12px; + margin-bottom: 8px; + border-radius: 8px; + border: 1px solid #30363d; + background: rgba(22, 27, 34, 0.9); + font-size: 0.8rem; + z-index: 2; +} + +.world-map-sales-panel .sales-panel-title { + font-weight: 600; + color: #8b949e; + margin-bottom: 4px; + margin-top: 8px; +} + +.world-map-sales-panel .sales-panel-title:first-child { + margin-top: 0; +} + +.world-map-sales-panel .sales-panel-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 4px 0; +} + +.world-map-sales-panel .sales-panel-row .offer-emoji { + font-size: 1.1rem; +} + +.world-map-sales-panel .sales-panel-actions { + display: flex; + gap: 4px; + margin-left: auto; +} + +.world-map-sales-panel .sales-btn-accept, +.world-map-sales-panel .sales-btn-reject, +.world-map-sales-panel .sales-btn-deliver, +.world-map-sales-panel .sales-btn-bid { + padding: 2px 8px; + font-size: 0.75rem; + border-radius: 4px; + cursor: pointer; + border: 1px solid #30363d; + background: #21262d; + color: #e6edf3; +} + +.world-map-sales-panel .sales-btn-accept:hover, +.world-map-sales-panel .sales-btn-deliver:hover, +.world-map-sales-panel .sales-btn-bid:hover { + background: #238636; + border-color: #2ea043; +} + +.world-map-sales-panel .sales-btn-reject:hover { + background: #da3633; + border-color: #f85149; +} + +.world-map-sales-panel .sales-pending-validation { + font-size: 0.7rem; + color: #8b949e; + margin-right: 6px; +} + +.world-map-sales-panel .sales-btn-deliver:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.world-map-sales-panel .sales-bid-input { + width: 64px; + padding: 2px 6px; + font-size: 0.75rem; + border-radius: 4px; + border: 1px solid #30363d; + background: #0d1117; + color: #e6edf3; +} + +.world-map-zoo-slot { + min-height: 52px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 4px; +} + +.world-map-zoo-icon { + font-size: 1.8rem; + line-height: 1; + opacity: 0.9; +} + +.world-map-offer-single { + width: 100%; + min-width: 0; + padding: 6px 8px; + font-size: 0.75rem; +} + +.world-map-offer-single .offer-emoji { + font-size: 1.2rem; +} + +.world-map-offer-single .offer-label { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.world-map-offer-single .offer-price { + font-size: 0.7rem; +} + +.world-map-city { + position: absolute; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + font-size: 1.5rem; + line-height: 1.2; + pointer-events: none; + z-index: 2; +} + +.world-map-city-label { + font-size: 0.6rem; + color: #8b949e; + margin-top: 2px; + white-space: nowrap; +} + +.world-map-city-max-visitors { + font-size: 0.55rem; + color: #6e7681; + margin-top: 1px; +} + +.world-map-lab { + position: absolute; + transform: translate(-50%, -50%); + min-width: 70px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid #6f42c1; + background: rgba(111, 66, 193, 0.2); + font-size: 0.7rem; + z-index: 2; +} + +.world-map-lab .world-map-zoo-name { + color: #d2a8ff; +} + +.world-map-trucks { + position: absolute; + inset: 12px; + pointer-events: none; + z-index: 10; +} + +.world-map-truck-npc { + position: absolute; + transform: translate(-50%, -50%); + font-size: 1.2rem; + opacity: 0.9; +} + +.world-map-zoo { + position: absolute; + transform: translate(-50%, -50%); + min-width: 70px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid #30363d; + background: #21262d; + font-size: 0.7rem; + z-index: 2; +} + +.world-map-zoo-player { + border-color: #58a6ff; + background: rgba(88, 166, 255, 0.15); +} + +.world-map-zoo-name { + font-weight: 600; + color: #e6edf3; + margin-bottom: 4px; + white-space: nowrap; +} + +.world-map-zoo-reproduction-score { + font-size: 0.7rem; + color: #8b949e; + margin-bottom: 2px; + white-space: nowrap; +} + +.world-map-zoo-attractivity-score { + font-size: 0.7rem; + color: #8b949e; + margin-bottom: 2px; + white-space: nowrap; +} + +.world-map-zoo-indicators { + font-size: 0.7rem; + color: #8b949e; + margin-bottom: 2px; + white-space: nowrap; +} + +.world-map-zoo-offers { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: center; +} + +.world-map-offer { + width: 64px; + padding: 4px 6px; + font-size: 0.65rem; +} + +.world-map-offer .offer-emoji { + font-size: 1.2rem; +} + +.world-map-truck { + position: absolute; + transform: translate(-50%, -50%); + font-size: 1.5rem; + pointer-events: none; + z-index: 10; + transition: left 0.05s linear, top 0.05s linear; +} + +.actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.actions button { + padding: 10px 14px; + border-radius: 8px; + border: 1px solid #30363d; + background: linear-gradient(180deg, #21262d 0%, #161b22 100%); + color: #e6edf3; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.15s, border-color 0.15s; +} + +.actions button:hover { + background: #30363d; + border-color: #58a6ff; +} + +.grid-wrap { + overflow: auto; + padding: 12px; + border: 1px solid #30363d; + border-radius: 10px; + background: #0d1117; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3); + position: relative; +} + +.grid { + display: inline-grid; + gap: 4px; + padding: 6px; +} + +.plot-upgrade-zone { + position: absolute; + right: 12px; + bottom: 76px; + width: 56px; + height: 56px; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 2px dashed #3d5c38; + background: rgba(45, 74, 42, 0.95); + color: #e6edf3; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.plot-upgrade-zone:hover, +.plot-upgrade-zone.can-upgrade { + border-color: #58a6ff; + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.3); +} + +.plot-upgrade-zone-icon { + font-size: 1.5rem; + line-height: 1; + display: block; +} + +.plot-upgrade-zone-label { + font-size: 0.6rem; + margin-top: 2px; +} + +.plot-upgrade-zone-arrow { + display: none; + font-size: 0.7rem; + margin-top: 2px; + color: #58a6ff; +} + +.plot-upgrade-zone.can-upgrade .plot-upgrade-zone-arrow { + display: block; +} + +.sell-zone { + position: absolute; + right: 12px; + bottom: 12px; + width: 56px; + height: 56px; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 2px dashed #8b949e; + background: rgba(33, 38, 45, 0.95); + color: #8b949e; + cursor: default; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.sell-zone:hover, +.sell-zone.dragover { + border-color: #58a6ff; + background: rgba(88, 166, 255, 0.15); + color: #e6edf3; +} + +.sell-zone-icon { + font-size: 1.5rem; + line-height: 1; + display: block; +} + +.sell-zone-label { + font-size: 0.6rem; + margin-top: 2px; +} + +.sell-zone.can-upgrade .sell-zone-upgrade-arrow { + display: block; +} + +.sell-zone-upgrade-arrow { + display: none; + font-size: 0.7rem; + margin-top: 2px; + color: #58a6ff; +} + +.cell.school { + background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%); + color: #e6edf3; + border-color: #4a5568; +} + +.cell.school.can-upgrade { + box-shadow: 0 0 0 2px #58a6ff; +} + +.cell.plot-upgrade { + background: linear-gradient(145deg, #2d4a2a 0%, #1e3320 100%); + color: #e6edf3; + border-color: #3d5c38; +} + +.cell.plot-upgrade.can-upgrade { + box-shadow: 0 0 0 2px #58a6ff; +} + +.cell.nursery { + background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%); + color: #e6edf3; + border-color: #4a5568; +} + +.cell.souvenir-shop { + background: linear-gradient(145deg, #3d2a1a 0%, #2a1e14 100%); + color: #e6edf3; + border-color: #5c4033; +} + +.cell.research { + background: linear-gradient(145deg, #1e3a5f 0%, #152a48 100%); + color: #e6edf3; + border-color: #2d4a6f; +} + +.cell.billeterie { + background: linear-gradient(145deg, #2d3a1a 0%, #1e2a14 100%); + color: #e6edf3; + border-color: #4a5c33; +} + +.cell.food { + background: linear-gradient(145deg, #3a2d1a 0%, #2a2214 100%); + color: #e6edf3; + border-color: #5c4a33; +} + +.cell.reception { + background: linear-gradient(145deg, #2a1e3a 0%, #1a1528 100%); + color: #e6edf3; + border-color: #4a3d5c; +} + +.cell.biome-change-color { + background: linear-gradient(145deg, #3a1e2a 0%, #2a1520 100%); + color: #e6edf3; + border-color: #5c334a; +} + +.cell.biome-change-temp { + background: linear-gradient(145deg, #1e2a3a 0%, #152230 100%); + color: #e6edf3; + border-color: #334a5c; +} + +.cell.empty-choice { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + justify-content: center; + padding: 4px; +} + +.cell.empty-choice .cell-choice-btn { + font-size: 0.55rem; + padding: 3px 5px; +} + +.cell-choice-btn { + font-size: 0.7rem; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid #30363d; + background: #21262d; + color: #e6edf3; + cursor: pointer; +} + +.cell-choice-btn:hover { + border-color: #58a6ff; + background: rgba(88, 166, 255, 0.15); +} + +.cell.world-map-upgrade { + background: linear-gradient(145deg, #1e3a5f 0%, #152a48 100%); + color: #e6edf3; + border-color: #2d4a6f; +} + +.cell.world-map-upgrade.can-upgrade { + box-shadow: 0 0 0 2px #58a6ff; +} + +.cell-upgrade-arrow { + display: block; + font-size: 0.65rem; + color: #58a6ff; + margin-top: 1px; +} + +.visitors-layer { + position: absolute; + top: 12px; + left: 12px; + right: 12px; + bottom: 12px; + pointer-events: none; + z-index: 1; +} + +.visitor-sprite { + position: absolute; + width: 20px; + height: 20px; + font-size: 16px; + line-height: 20px; + text-align: center; + transition: left 0.15s linear, top 0.15s linear; + will-change: left, top; +} + +.visitor-incident-bubble { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 2px; + font-size: 14px; + cursor: pointer; + padding: 2px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(0, 0, 0, 0.15); + line-height: 1; +} + +.visitor-incident-bubble:hover { + background: #fff; + border-color: rgba(0, 0, 0, 0.25); +} + +.cell { + width: 48px; + height: 48px; + border-radius: 8px; + border: 2px solid transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 0.6rem; + cursor: pointer; + background: #21262d; + color: #8b949e; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s; + line-height: 1.1; + user-select: none; + -webkit-user-select: none; +} + +.cell:hover { + transform: scale(1.05); +} + +.cell.empty { + background: #161b22; + border-style: dashed; + border-color: #30363d; +} + +.cell.biome-meadow.empty { + background: linear-gradient(145deg, #2d4a2a 0%, #1e3320 100%); + border-color: #3d5c38; +} + +.cell.biome-freshwater.empty { + background: linear-gradient(145deg, #1e4d5f 0%, #153a48 100%); + border-color: #2d5c6f; +} + +.cell.biome-ocean.empty { + background: linear-gradient(145deg, #1e3a5f 0%, #152a48 100%); + border-color: #2d4a6f; +} + +.cell.biome-forest.empty { + background: linear-gradient(145deg, #1e3d1e 0%, #152a15 100%); + border-color: #2d4a2d; +} + +.cell.biome-mountain.empty { + background: linear-gradient(145deg, #4a4a4a 0%, #2d2d2d 100%); + border-color: #5c5c5c; +} + +.cell.temp-low.empty { filter: brightness(0.9) hue-rotate(-5deg); } +.cell.temp-mid.empty { } +.cell.temp-high.empty { filter: brightness(1.08) hue-rotate(5deg); } + +.cell.egg.biome-meadow { border-color: #6b8e23; } +.cell.egg.biome-ocean { border-color: #1e90ff; } +.cell.egg.biome-mountain { border-color: #8b7355; } +.cell.animal.biome-meadow { box-shadow: 0 0 8px rgba(107, 142, 35, 0.4); } +.cell.animal.biome-ocean { box-shadow: 0 0 8px rgba(30, 144, 255, 0.4); } +.cell.animal.biome-mountain { box-shadow: 0 0 8px rgba(139, 115, 85, 0.4); } + +.cell.just-hatched { + animation: hatch-pop 0.6s ease-out; +} + +@keyframes hatch-pop { + 0% { transform: scale(0.3); opacity: 0.8; } + 50% { transform: scale(1.15); } + 100% { transform: scale(1); opacity: 1; } +} + +.cell.animal .cell-emoji { + animation: animal-idle 2.5s ease-in-out infinite; +} + +@keyframes animal-idle { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.08); } +} + +.cell.selected { + border-color: #58a6ff; + box-shadow: 0 0 0 2px #58a6ff; +} + +.cell.egg { + background: linear-gradient(145deg, #d4a84b 0%, #b8860b 100%); + color: #1a1e24; + border-color: #daa520; + font-weight: 600; +} + +.cell.animal { + background: linear-gradient(145deg, #388bfd 0%, #1f6feb 100%); + color: #fff; + border-color: #58a6ff; + font-weight: 600; +} + +.cell .cell-emoji { + font-size: 1.4rem; + display: block; + margin-bottom: 0; +} + +.cell .cell-label { + font-size: 0.55rem; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cell.dragging { + opacity: 0.6; + cursor: grabbing; +} + +.cell-draggable { + cursor: grab; +} + +.drag-ghost { + position: fixed; + left: -9999px; + top: 0; + width: 48px; + height: 48px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 2px solid #30363d; + box-sizing: border-box; + pointer-events: none; +} + +.drag-ghost.cell.egg { + background: linear-gradient(145deg, #d4a84b 0%, #b8860b 100%); + color: #1a1e24; +} + +.drag-ghost.cell.animal { + background: linear-gradient(145deg, #388bfd 0%, #1f6feb 100%); + color: #fff; +} + +.cell.dragover { + outline: 2px dashed #58a6ff; + outline-offset: 2px; +} + +.error-msg { + color: #f85149; + font-size: 0.85rem; + margin-top: 10px; + padding: 8px; + background: rgba(248, 81, 73, 0.1); + border-radius: 6px; + border-left: 3px solid #f85149; +} + +.error-msg[hidden] { + display: none; + margin: 0; + padding: 0; + min-height: 0; +} + +/* Bulles d'aide */ +.help-wrap { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: #30363d; + color: #58a6ff; + font-size: 0.75rem; + font-weight: 700; + cursor: help; + border: 1px solid #484f58; + flex-shrink: 0; +} + +.help-icon:hover { + background: #388bfd; + color: #fff; +} + +.help-icon:hover + .tooltip-bubble, +.tooltip-bubble:hover { + opacity: 1; + visibility: visible; +} + +.tooltip-bubble { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 8px; + padding: 10px 12px; + max-width: 260px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + font-size: 0.8rem; + color: #e6edf3; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0.2s; + z-index: 100; + pointer-events: none; +} + +.help-wrap .tooltip-bubble { + pointer-events: auto; +} + +.section-with-help { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.section-with-help .help-wrap { + order: 1; +} + +.section-with-help h2 { + margin: 0; +} + +.tooltip-bubble.below { + left: 0; + top: 100%; + margin-left: 0; + margin-top: 8px; + transform: none; +} + +.status-small { + font-size: 0.8rem; + margin-bottom: 4px; +} + +.time-weather { + text-transform: capitalize; +} + +.weather-rain { color: #79c0ff; } +.weather-cloudy { color: #8b949e; } + +.quest-panel { + min-width: 260px; +} + +.quest-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.quest-item { + font-size: 0.85rem; + padding: 6px 10px; + background: #0d1117; + border-radius: 6px; + border-left: 3px solid #58a6ff; +} + +.quest-item.done { + border-left-color: #3fb950; + opacity: 0.85; +} + +.prestige-btn { + margin-top: 12px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid #da3633; + background: linear-gradient(180deg, #3d1f1f 0%, #2a1515 100%); + color: #f85149; + cursor: pointer; + font-size: 0.9rem; + width: 100%; +} + +.prestige-btn:hover:not(:disabled) { + background: #4d2525; + border-color: #f85149; +} + +.prestige-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.restart-btn { + margin-top: 8px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid #30363d; + background: #21262d; + color: #8b949e; + cursor: pointer; + font-size: 0.9rem; + width: 100%; +} + +.restart-btn:hover:not(:disabled) { + background: #30363d; + border-color: #58a6ff; + color: #e6edf3; +} + +.restart-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3268ea0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,19 @@ + + + + + + Construis un zoo + + + +
+ + + + diff --git a/web/js/animal-visits.js b/web/js/animal-visits.js new file mode 100644 index 0000000..2aecc00 --- /dev/null +++ b/web/js/animal-visits.js @@ -0,0 +1,45 @@ +/** + * Animals disappear after a duration without any visitor on their cell. + * Visitors are simulated at their current orbit position; cells under a visitor get lastVisitedAt updated. + * Death removal is handled by checkDeathCauses in food.js. + */ + +import { getVisitorCount } from "./income.js"; +import { getAttractionCenter, getVisitorPosition, getCellKeyFromPixelPosition } from "./visitor-attraction.js"; + +/** + * Update lastVisitedAt for animal cells when a visitor is on that cell. + * Does not remove animals; checkDeathCauses(state, nowUnix) handles death from no visit. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {number} nowMs + */ +export function tickAnimalVisits(state, nowUnix, nowMs) { + const gridWidth = state.grid.width; + const gridHeight = state.grid.height; + const cells = state.grid.cells; + + const visitorCount = getVisitorCount(state); + if (visitorCount > 0) { + const { centerX, centerY } = getAttractionCenter(state, gridWidth, gridHeight); + const t = nowMs / 1000; + for (let i = 0; i < visitorCount; i++) { + const { px, py } = getVisitorPosition({ + i, + n: visitorCount, + t, + centerX, + centerY, + gridWidth, + gridHeight, + }); + const key = getCellKeyFromPixelPosition(px, py, gridWidth, gridHeight); + if (key !== "") { + const cell = cells[key]; + if (cell !== null && cell !== undefined && cell.kind === "animal") { + cell.lastVisitedAt = nowUnix; + } + } + } + } +} diff --git a/web/js/api-client.js b/web/js/api-client.js new file mode 100644 index 0000000..e47b5fb --- /dev/null +++ b/web/js/api-client.js @@ -0,0 +1,258 @@ +/** + * 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}`); + } +} diff --git a/web/js/audio.js b/web/js/audio.js new file mode 100644 index 0000000..7b4228c --- /dev/null +++ b/web/js/audio.js @@ -0,0 +1,245 @@ +let audioCtx = null; + +function getCtx() { + if (audioCtx === null || audioCtx === undefined) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + return audioCtx; +} + +function applySoundUpgrade(osc, now) { + osc.frequency.setValueAtTime(659, now); + osc.frequency.setValueAtTime(784, now + 0.06); +} + +function applySoundHatch(osc, now) { + osc.type = "sine"; + osc.frequency.setValueAtTime(392, now); + osc.frequency.setValueAtTime(523, now + 0.08); + osc.frequency.setValueAtTime(659, now + 0.16); +} + +function applySoundPlace(osc, now) { + osc.type = "sine"; + osc.frequency.setValueAtTime(659, now); + osc.frequency.setValueAtTime(523, now + 0.05); + osc.frequency.setValueAtTime(784, now + 0.1); +} + +function applySoundBuy(osc, now) { + osc.type = "sine"; + osc.frequency.setValueAtTime(523, now); + osc.frequency.setValueAtTime(659, now + 0.05); + osc.frequency.setValueAtTime(784, now + 0.1); +} + +function applySoundSell(osc, now) { + osc.type = "triangle"; + osc.frequency.setValueAtTime(440, now); + osc.frequency.setValueAtTime(349, now + 0.06); + osc.frequency.setValueAtTime(262, now + 0.12); +} + +function applySoundPlotUpgrade(osc, now) { + osc.type = "sine"; + osc.frequency.setValueAtTime(330, now); + osc.frequency.setValueAtTime(440, now + 0.06); + osc.frequency.setValueAtTime(554, now + 0.12); +} + +function applySoundTruckUpgrade(osc, now) { + osc.type = "square"; + osc.frequency.setValueAtTime(220, now); + osc.frequency.setValueAtTime(277, now + 0.05); + osc.frequency.setValueAtTime(330, now + 0.1); +} + +function applySoundSchoolUpgrade(osc, now) { + osc.type = "sine"; + osc.frequency.setValueAtTime(523, now); + osc.frequency.setValueAtTime(659, now + 0.05); + osc.frequency.setValueAtTime(784, now + 0.1); + osc.frequency.setValueAtTime(1047, now + 0.15); +} + +function applySoundWorldMapUpgrade(osc, now) { + osc.type = "sine"; + osc.frequency.setValueAtTime(440, now); + osc.frequency.setValueAtTime(554, now + 0.06); + osc.frequency.setValueAtTime(698, now + 0.12); +} + +function applySoundQuest(osc, now) { + osc.frequency.setValueAtTime(659, now); + osc.frequency.setValueAtTime(784, now + 0.06); +} + +function applySoundError(osc, now) { + osc.frequency.setValueAtTime(200, now); + osc.frequency.setValueAtTime(180, now + 0.08); +} + +function applySoundDefault(osc, now) { + osc.frequency.setValueAtTime(440, now); +} + +const SOUND_APPLIERS = { + upgrade: applySoundUpgrade, + hatch: applySoundHatch, + place: applySoundPlace, + buy: applySoundBuy, + sell: applySoundSell, + plotUpgrade: applySoundPlotUpgrade, + truckUpgrade: applySoundTruckUpgrade, + schoolUpgrade: applySoundSchoolUpgrade, + worldMapUpgrade: applySoundWorldMapUpgrade, + quest: applySoundQuest, + error: applySoundError, +}; + +/** + * @param {string} type One of: hatch, place, buy, sell, plotUpgrade, truckUpgrade, schoolUpgrade, worldMapUpgrade, upgrade, quest, error + * @returns {void} + */ +export function playSound(type) { + try { + const ctx = getCtx(); + const now = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + gain.gain.setValueAtTime(0.15, now); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15); + osc.start(now); + osc.stop(now + 0.15); + const applier = SOUND_APPLIERS[type] || applySoundDefault; + applier(osc, now); + } catch (e) { + console.warn("playSound failed", e); + } +} + +let musicEnabled = false; +let musicIntervalId = null; +let musicGetState = () => null; +const MUSIC_BEAT_MS = 400; + +/** + * Register state getter so music can switch by day/night/truck. + * @param {() => { timeOfDay?: number, truckSale?: { startAt: number } }} getState + */ +export function setMusicGetState(getState) { + musicGetState = getState || (() => null); +} + +function isNight(timeOfDay) { + const t = (timeOfDay ?? 6) % 24; + return t < 6 || t >= 20; +} + +function isTruckMoving(state) { + const sale = state.truckSale; + if (!sale || !sale.startAt) return false; + const truckMs = 2500; + return (Date.now() - sale.startAt) < truckMs; +} + +function playMusicVoliere() { + if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return; + try { + const ctx = getCtx(); + const now = ctx.currentTime; + const gain = ctx.createGain(); + gain.connect(ctx.destination); + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.04, now + 0.02); + gain.gain.exponentialRampToValueAtTime(0.004, now + 0.25); + const freq = 600 + Math.random() * 800; + const osc = ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.setValueAtTime(freq, now); + osc.connect(gain); + osc.start(now); + osc.stop(now + 0.25); + } catch (e) { + console.warn("playMusicVoliere failed", e); + } +} + +function playMusicBrahms() { + if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return; + try { + const ctx = getCtx(); + const now = ctx.currentTime; + const gain = ctx.createGain(); + gain.connect(ctx.destination); + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.05, now + 0.03); + gain.gain.exponentialRampToValueAtTime(0.005, now + 0.5); + const waltz = [415.3, 523.25, 622.25]; + waltz.forEach((freq, i) => { + const osc = ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.setValueAtTime(freq, now + i * 0.15); + osc.connect(gain); + osc.start(now + i * 0.15); + osc.stop(now + 0.5); + }); + } catch (e) { + console.warn("playMusicBrahms failed", e); + } +} + +function playMusicTrepak() { + if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return; + try { + const ctx = getCtx(); + const now = ctx.currentTime; + const gain = ctx.createGain(); + gain.connect(ctx.destination); + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.08, now + 0.02); + gain.gain.exponentialRampToValueAtTime(0.006, now + 0.12); + const osc = ctx.createOscillator(); + osc.type = "triangle"; + osc.frequency.setValueAtTime(280, now); + osc.frequency.setValueAtTime(350, now + 0.06); + osc.connect(gain); + osc.start(now); + osc.stop(now + 0.12); + } catch (e) { + console.warn("playMusicTrepak failed", e); + } +} + +function playMusicTick() { + if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return; + const state = musicGetState(); + if (state && isTruckMoving(state)) { + playMusicTrepak(); + return; + } + const timeOfDay = state && state.timeOfDay !== null && state.timeOfDay !== undefined ? state.timeOfDay : 6; + if (isNight(timeOfDay)) { + playMusicBrahms(); + } else { + playMusicVoliere(); + } +} + +/** Call to enable/disable background music (procedural: Volière / Brahms / Trepak). */ +export function setMusicEnabled(enabled) { + if (musicIntervalId !== null && musicIntervalId !== undefined) { + clearInterval(musicIntervalId); + musicIntervalId = null; + } + musicEnabled = Boolean(enabled); + if (musicEnabled) { + getCtx(); + playMusicTick(); + musicIntervalId = setInterval(playMusicTick, MUSIC_BEAT_MS); + } +} + +export function isMusicEnabled() { + return musicEnabled; +} diff --git a/web/js/auth-client.js b/web/js/auth-client.js new file mode 100644 index 0000000..5e4cad4 --- /dev/null +++ b/web/js/auth-client.js @@ -0,0 +1,100 @@ +/** + * Ed25519 keypair: generate, store in localStorage, sign requests. + * Public key exported as SPKI base64url for server verification. + */ + +const STORAGE_KEY_PUBLIC = "builazoo_public_key"; +const STORAGE_KEY_PRIVATE = "builazoo_private_key"; + +/** + * @returns {Promise} + */ +function generateKeyPair() { + return crypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"] + ); +} + +/** + * @param {ArrayBuffer} buf + * @returns {string} + */ +function base64url(buf) { + const b64 = btoa(String.fromCharCode(...new Uint8Array(buf))); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * @returns {Promise<{ publicKeyBase64: string, privateKey: CryptoKey } | null>} + */ +export async function getOrCreateKeyPair() { + if (typeof crypto === "undefined" || !crypto.subtle) { + throw new Error("Connexion sécurisée requise (HTTPS ou localhost) pour créer un compte."); + } + try { + const pubRaw = localStorage.getItem(STORAGE_KEY_PUBLIC); + const privRaw = localStorage.getItem(STORAGE_KEY_PRIVATE); + if (pubRaw && privRaw) { + const privateKey = await crypto.subtle.importKey( + "pkcs8", + base64urlDecodeToBuf(privRaw), + { name: "Ed25519" }, + true, + ["sign"] + ); + await crypto.subtle.importKey( + "spki", + base64urlDecodeToBuf(pubRaw), + { name: "Ed25519" }, + true, + ["verify"] + ); + return { publicKeyBase64: pubRaw, privateKey }; + } + const pair = await generateKeyPair(); + const [pubExported, privExported] = await Promise.all([ + crypto.subtle.exportKey("spki", pair.publicKey), + crypto.subtle.exportKey("pkcs8", pair.privateKey), + ]); + localStorage.setItem(STORAGE_KEY_PUBLIC, base64url(pubExported)); + localStorage.setItem(STORAGE_KEY_PRIVATE, base64url(privExported)); + return { publicKeyBase64: base64url(pubExported), privateKey: pair.privateKey }; + } catch (e) { + console.warn("getOrCreateKeyPair failed", e); + return null; + } +} + +/** + * @param {string} str base64url + * @returns {ArrayBuffer} + */ +function base64urlDecodeToBuf(str) { + const pad = (4 - (str.length % 4)) % 4; + const b64 = (str + "==".slice(0, pad)).replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(b64); + const buf = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i); + return buf.buffer; +} + +/** + * @param {CryptoKey} privateKey + * @param {string} message + * @returns {Promise} base64url signature + */ +export async function signMessage(privateKey, message) { + const enc = new TextEncoder().encode(message); + const sig = await crypto.subtle.sign("Ed25519", privateKey, enc); + return base64url(sig); +} + +/** + * Call after register to ensure we have keys stored (already done by getOrCreateKeyPair). + * @returns {boolean} true if keys exist + */ +export function hasStoredKeys() { + return Boolean(localStorage.getItem(STORAGE_KEY_PUBLIC) && localStorage.getItem(STORAGE_KEY_PRIVATE)); +} diff --git a/web/js/auto-mode-profiles.js b/web/js/auto-mode-profiles.js new file mode 100644 index 0000000..cbeccd9 --- /dev/null +++ b/web/js/auto-mode-profiles.js @@ -0,0 +1,102 @@ +/** + * Auto-mode profiles: 50 specialisations in 5 families. + * Used by player auto mode and bot zoos (bots still use legacy random fast/slow/balanced). + * Params: spendThreshold (multiplier on cost to allow spend), upgradeChance (0–1), sellChance (0–1, bots only). + */ + +/** @typedef {{ id: number, familyId: number, spendThreshold: number, upgradeChance: number, sellChance: number, labelKey: string, prioritiesKey: string, risksKey: string }} AutoModeProfile */ + +/** Families 1–5: Conservateurs, Éleveurs, Commerçants, Expansionnistes, Scientifiques. */ +export const AUTO_MODE_FAMILY_IDS = [1, 2, 3, 4, 5]; + +/** Profile id range per family: 1–10, 11–20, 21–30, 31–40, 41–50. */ +const FAMILY_RANGES = [[1, 10], [11, 20], [21, 30], [31, 40], [41, 50]]; + +/** Legacy profile name to default profile id (balanced=25, fast=33, slow=7). */ +export const LEGACY_PROFILE_TO_ID = { balanced: 25, fast: 33, slow: 7 }; + +/** + * All 50 profiles. Params tuned per family. + * @type {AutoModeProfile[]} + */ +const PROFILES = []; + +function buildProfiles() { + for (let familyId = 1; familyId <= 5; familyId++) { + const [minId, maxId] = FAMILY_RANGES[familyId - 1]; + for (let id = minId; id <= maxId; id++) { + const i = (id - minId) / (maxId - minId); + const spendThreshold = 0.5 + i * 2; + const upgradeChance = 0.05 + i * 0.5; + const sellChance = familyId === 2 ? 0.02 + i * 0.08 : familyId === 3 ? 0.08 + i * 0.12 : 0.02 + i * 0.1; + PROFILES.push({ + id, + familyId, + spendThreshold: Math.round(spendThreshold * 100) / 100, + upgradeChance: Math.round(upgradeChance * 100) / 100, + sellChance: Math.round(sellChance * 100) / 100, + labelKey: `autoProfile.specialisation.${id}`, + prioritiesKey: `autoProfile.priorities.${id}`, + risksKey: `autoProfile.risks.${id}`, + }); + } + } +} +buildProfiles(); + +/** + * Resolve effective profile id from state (autoModeProfileId or legacy autoModeProfile). + * @param {{ autoModeProfileId?: number, autoModeProfile?: string }} state + * @returns {number} + */ +export function getEffectiveProfileId(state) { + const id = state.autoModeProfileId; + if (id !== null && id !== undefined && id >= 1 && id <= 50) return id; + const legacy = state.autoModeProfile; + if (legacy === "fast" || legacy === "slow" || legacy === "balanced") return LEGACY_PROFILE_TO_ID[legacy]; + return LEGACY_PROFILE_TO_ID.balanced; +} + +/** + * Get profile by id (1–50). Returns default balanced params if not found. + * @param {number} profileId + * @returns {AutoModeProfile} + */ +export function getProfileById(profileId) { + const p = PROFILES.find((x) => x.id === profileId); + if (p) return p; + const def = PROFILES.find((x) => x.id === 25); + return def ?? PROFILES[0]; +} + +/** + * Params for use in bot/player auto logic. + * @param {number} profileId + * @returns {{ spendThreshold: number, upgradeChance: number, sellChance: number }} + */ +export function getProfileParams(profileId) { + const p = getProfileById(profileId); + return { + spendThreshold: p.spendThreshold, + upgradeChance: p.upgradeChance, + sellChance: p.sellChance, + }; +} + +/** + * All profiles in the given family (1–5). + * @param {number} familyId + * @returns {AutoModeProfile[]} + */ +export function getProfilesByFamily(familyId) { + return PROFILES.filter((p) => p.familyId === familyId); +} + +/** All 50 profiles. + * @returns {AutoModeProfile[]} + */ +export function getAllProfiles() { + return [...PROFILES]; +} + +export { FAMILY_RANGES }; diff --git a/web/js/biome-rules.js b/web/js/biome-rules.js new file mode 100644 index 0000000..e589009 --- /dev/null +++ b/web/js/biome-rules.js @@ -0,0 +1,124 @@ +/** + * Biomes: Meadow, Freshwater, Ocean, Forest, Mountain. + * Each cell has a biome and a temperature; display uses optional interpolation for smooth transitions. + */ + +export const BIOMES = ["Meadow", "Freshwater", "Ocean", "Forest", "Mountain"]; + +/** + * Base biome from grid position (5 zones by column). + * @param {number} width + * @param {number} height + * @param {number} x 1-based column + * @param {number} y 1-based row + * @returns {string} + */ +export function getCellBiome(width, height, x, y) { + const w = Math.max(1, width); + const h = Math.max(1, height); + const col = Math.max(1, Math.min(w, Math.floor(x))); + const _row = Math.max(1, Math.min(h, Math.floor(y))); + const t = Math.floor((col - 1) / (w / 5)); + const index = Math.min(4, Math.max(0, t)); + return BIOMES[index] ?? "Meadow"; +} + +/** + * Backward-compat: getCellBiome with 2 args (width, x) returns Meadow/Ocean/Mountain by column thirds. + * @param {number} width + * @param {number} x + * @returns {string} + */ +export function getCellBiomeLegacy(width, x) { + const third = Math.max(1, Math.floor(width / 3)); + if (x <= third) return "Meadow"; + if (x <= third * 2) return "Ocean"; + return "Mountain"; +} + +/** + * Base temperature at cell (smooth gradient by position). Range about 10–28. + * @param {number} width + * @param {number} height + * @param {number} x 1-based + * @param {number} y 1-based + * @returns {number} + */ +export function getCellTemperature(width, height, x, y) { + const w = Math.max(1, width); + const h = Math.max(1, height); + const nx = (Math.max(1, Math.min(w, Math.floor(x))) - 0.5) / w; + const ny = (Math.max(1, Math.min(h, Math.floor(y))) - 0.5) / h; + return 10 + ny * 14 + nx * 4; +} + +/** + * Display biome at (x, y) — for now the cell's own biome (no string interpolation). + * @param {number} x 1-based + * @param {number} y 1-based + * @param {{ width: number, height: number }} grid + * @returns {string} + */ +export function getDisplayBiome(x, y, grid) { + return getCellBiome(grid.width, grid.height, x, y); +} + +/** + * Display temperature at (x, y) with smooth interpolation from neighbours. + * @param {number} x 1-based + * @param {number} y 1-based + * @param {{ width: number, height: number }} grid + * @returns {number} + */ +export function getDisplayTemperature(x, y, grid) { + const w = grid.width; + const h = grid.height; + let sum = 0; + let count = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 1 && nx <= w && ny >= 1 && ny <= h) { + sum += getCellTemperature(w, h, nx, ny); + count += 1; + } + } + } + return count > 0 ? sum / count : getCellTemperature(w, h, x, y); +} + +/** + * Temperature band for CSS class: low (< 16), mid (16–22), high (> 22). + * @param {number} temp + * @returns {"low"|"mid"|"high"} + */ +export function getTemperatureBand(temp) { + if (temp < 16) return "low"; + if (temp <= 22) return "mid"; + return "high"; +} + +/** + * @param {string} animalBiome + * @param {string} cellBiome + * @returns {boolean} + */ +export function isAnimalAllowedOnBiome(animalBiome, cellBiome) { + if (animalBiome === cellBiome) return true; + if (animalBiome === "Meadow" && cellBiome === "Forest") return true; + if (animalBiome === "Ocean" && cellBiome === "Freshwater") return true; + return false; +} + +/** + * Biomes that are compatible with this cell for loot/placement (cell biome + compatible animal biomes). + * @param {string} cellBiome + * @returns {string[]} + */ +export function getBiomesCompatibleWithCell(cellBiome) { + const set = [cellBiome]; + if (cellBiome === "Forest") set.push("Meadow"); + if (cellBiome === "Freshwater") set.push("Ocean"); + return set; +} diff --git a/web/js/bot-zoo.js b/web/js/bot-zoo.js new file mode 100644 index 0000000..0a6d5d9 --- /dev/null +++ b/web/js/bot-zoo.js @@ -0,0 +1,331 @@ +/** + * Bot zoos: same indicators and formulas as player (coins, plotLevel, conveyorLevel, truckLevel). + * Decisions (buy, sell, upgrade) follow a randomly chosen profile (fast / slow / balanced). + * Egg color choice is weighted by neighboring zoos' animalWeights. + */ + +import { GameConfig } from "./config.js"; +import { LootTables, getColorNames, zeroAnimalWeights } from "./loot-tables.js"; +import { getPlotUpgradeCost, getSchoolUpgradeCost, getTruckUpgradeCost, getConveyorUpgradeCost, getSellValue } from "./economy.js"; +import { getIncomeMultiplier } from "./mutation-rules.js"; +import { pickId } from "./weighted-random.js"; +import { tryUpgradePlot } from "./zoo.js"; +import { tryUpgrade, tryUpgradeTruck } from "./conveyor.js"; +import { getEffectiveProfileId, getProfileParams, LEGACY_PROFILE_TO_ID } from "./auto-mode-profiles.js"; + +const PROFILE_OPTIONS = ["fast", "slow", "balanced"]; + +/** + * @returns {import("./types.js").BotState} + */ +export function createInitialBotState() { + const cfg = GameConfig.Bots || {}; + const minC = cfg.InitialCoinsMin ?? 150; + const maxC = cfg.InitialCoinsMax ?? 450; + const coins = minC + Math.floor(Math.random() * (maxC - minC + 1)); + const profile = PROFILE_OPTIONS[Math.floor(Math.random() * PROFILE_OPTIONS.length)]; + return { + coins, + plotLevel: 1, + conveyorLevel: 1, + truckLevel: 1, + profile, + lastTickAt: 0, + }; +} + +/** + * @param {import("./types.js").WorldZooEntry} zoo + * @returns {number} + */ +export function getZooSkillLevel(zoo) { + const b = zoo.botState; + return b ? b.conveyorLevel : 1; +} + +/** + * Neighbor color weights (sum of animalWeights of zoos within maxDistance on the map). + * @param {import("./types.js").GameState} state + * @param {string} zooId + * @returns {Record} + */ +export function getNeighborColorWeights(state, zooId) { + const zoos = state.worldZoos ?? []; + const self = zoos.find((z) => z.id === zooId); + if (!self) return {}; + const maxD = (GameConfig.Bots && GameConfig.Bots.NeighborMaxDistance) ?? 35; + const colorNames = getColorNames(); + const out = zeroAnimalWeights(); + for (const z of zoos) { + if (z.id !== zooId) { + const dx = (z.x - self.x) / 100; + const dy = (z.y - self.y) / 100; + const dist = Math.sqrt(dx * dx + dy * dy) * 100; + if (dist <= maxD) { + const w = z.animalWeights ?? {}; + for (const c of colorNames) out[c] = (out[c] ?? 0) + (w[c] ?? 0); + } + } + } + return out; +} + +function getAverageIncomePerSecondForEggType(eggType) { + const def = LootTables.EggTypes[eggType]; + if (!def || !def.loot.length) return 0; + let sum = 0; + let totalWeight = 0; + for (const e of def.loot) { + const a = LootTables.Animals[e.id]; + if (a) { + const w = e.weight ?? 1; + sum += a.baseIncomePerSecond * w; + totalWeight += w; + } + } + return totalWeight > 0 ? sum / totalWeight : 0; +} + +function getAverageSellValueForEggType(eggType) { + const def = LootTables.EggTypes[eggType]; + if (!def || !def.loot.length) return 0; + let sum = 0; + let totalWeight = 0; + const mutMult = getIncomeMultiplier("none"); + for (const e of def.loot) { + const a = LootTables.Animals[e.id]; + if (a) { + const w = e.weight ?? 1; + const v = getSellValue(a.baseIncomePerSecond, 1, mutMult, a.sellFactor); + sum += v * w; + totalWeight += w; + } + } + return totalWeight > 0 ? Math.floor(sum / totalWeight) : 0; +} + +/** + * Add income for a bot from its animalWeights (same formula as player: income per "animal" per color). + * @param {import("./types.js").WorldZooEntry} zoo + * @param {number} dt seconds + */ +function tickBotIncome(zoo, dt) { + const b = zoo.botState; + if (!b) return; + const weights = zoo.animalWeights ?? {}; + const colorNames = getColorNames(); + let total = 0; + for (const eggType of colorNames) { + const count = weights[eggType] ?? 0; + if (count > 0) { + const avgIncome = getAverageIncomePerSecondForEggType(eggType); + total += count * avgIncome * dt; + } + } + const visitorPart = 0; + b.coins = Math.max(0, b.coins + total + visitorPart); +} + +/** + * Ensure zoo has botState (for non-player zoos). Mutates zoo. + * @param {import("./types.js").WorldZooEntry} zoo + * @param {boolean} isPlayer + */ +export function ensureBotState(zoo, isPlayer) { + if (isPlayer) return; + if (zoo.botState) return; + zoo.botState = createInitialBotState(); +} + +/** + * @param {import("./types.js").GameState} state + * @param {import("./types.js").WorldZooEntry} zoo + * @param {{ b: import("./types.js").BotState, rng: () => number, params: { spendThreshold: number, upgradeChance: number } }} opts + * @returns {boolean} + */ +function botDecideUpgrade(state, zoo, opts) { + const { b, rng, params } = opts; + const { spendThreshold, upgradeChance } = params; + const plotCost = getPlotUpgradeCost(b.plotLevel); + const plotMax = GameConfig.Plot?.MaxLevel ?? 8; + const skillCost = getSchoolUpgradeCost(b.conveyorLevel); + const skillMax = GameConfig.Conveyor?.MaxLevel ?? 8; + const truckCost = getTruckUpgradeCost(b.truckLevel); + const truckMax = (GameConfig.Truck && GameConfig.Truck.MaxLevel) ?? 5; + const canUpgradePlot = b.plotLevel < plotMax && b.coins >= plotCost * spendThreshold; + const canUpgradeSkill = b.conveyorLevel < skillMax && b.coins >= skillCost * spendThreshold; + const canUpgradeTruck = b.truckLevel < truckMax && b.coins >= truckCost * spendThreshold; + const upgradeChoices = []; + if (canUpgradePlot) upgradeChoices.push("plot"); + if (canUpgradeSkill) upgradeChoices.push("skill"); + if (canUpgradeTruck) upgradeChoices.push("truck"); + if (upgradeChoices.length === 0 || rng() >= upgradeChance) return false; + const choice = upgradeChoices[Math.floor(rng() * upgradeChoices.length)]; + if (choice === "plot" && b.coins >= plotCost) { + b.coins -= plotCost; + b.plotLevel += 1; + return true; + } + if (choice === "skill" && b.coins >= skillCost) { + b.coins -= skillCost; + b.conveyorLevel += 1; + return true; + } + if (choice === "truck" && b.coins >= truckCost) { + b.coins -= truckCost; + b.truckLevel += 1; + return true; + } + return false; +} + +/** + * @param {import("./types.js").WorldZooEntry} zoo + * @param {() => number} rng + * @param {{ sellChance: number }} params + * @returns {boolean} + */ +function botDecideSell(zoo, rng, params) { + const weights = zoo.animalWeights ?? {}; + const colorNames = getColorNames(); + const totalAnimals = colorNames.reduce((s, c) => s + (weights[c] ?? 0), 0); + if (totalAnimals <= 0 || rng() >= params.sellChance) return false; + const withWeight = colorNames.filter((c) => (weights[c] ?? 0) > 0).map((c) => ({ id: c, weight: weights[c] ?? 0 })); + if (withWeight.length === 0) return false; + const soldColor = pickId(rng, withWeight); + weights[soldColor] = (weights[soldColor] ?? 1) - 1; + if (weights[soldColor] <= 0) delete weights[soldColor]; + const b = zoo.botState; + if (b) b.coins += getAverageSellValueForEggType(soldColor); + return true; +} + +/** + * @param {import("./types.js").GameState} state + * @param {import("./types.js").WorldZooEntry} zoo + * @param {() => number} rng + * @param {{ spendThreshold: number }} params + * @returns {void} + */ +function botDecideBuy(state, zoo, rng, params) { + const b = zoo.botState; + if (!b) return; + const spendThreshold = params.spendThreshold; + const colorNames = getColorNames(); + const neighborWeights = getNeighborColorWeights(state, zoo.id); + const weights = zoo.animalWeights ?? {}; + const eligibleTypes = colorNames.filter((c) => { + const def = LootTables.EggTypes[c]; + return def && b.conveyorLevel >= def.minConveyorLevel; + }); + if (eligibleTypes.length === 0) return; + const neighborEntries = eligibleTypes.map((c) => ({ + id: c, + weight: Math.max(1, (neighborWeights[c] ?? 0) + (weights[c] ?? 0) * 2), + })); + const totalN = neighborEntries.reduce((s, e) => s + e.weight, 0); + if (totalN <= 0) return; + const eggType = pickId(rng, neighborEntries); + const eggDef = LootTables.EggTypes[eggType]; + if (!eggDef || b.coins < eggDef.price * spendThreshold) return; + b.coins -= eggDef.price; + weights[eggType] = (weights[eggType] ?? 0) + 1; +} + +/** + * One decision tick for one bot: possibly buy egg, sell "animal", or upgrade. + * @param {import("./types.js").GameState} state + * @param {import("./types.js").WorldZooEntry} zoo + * @param {number} nowUnix + */ +function tickBotDecisions(state, zoo, nowUnix) { + const b = zoo.botState; + if (!b) return; + const cfg = GameConfig.Bots || {}; + const minInterval = cfg.TickIntervalMinSeconds ?? 8; + const maxInterval = cfg.TickIntervalMaxSeconds ?? 25; + if (nowUnix - b.lastTickAt < minInterval) return; + const rng = () => Math.random(); + const intervalRange = maxInterval - minInterval + 1; + const nextInterval = minInterval + Math.floor(rng() * intervalRange); + if (nowUnix - b.lastTickAt < nextInterval) return; + b.lastTickAt = nowUnix; + + const profileId = LEGACY_PROFILE_TO_ID[b.profile] ?? 25; + const params = getProfileParams(profileId); + if (botDecideUpgrade(state, zoo, { b, rng, params })) return; + if (botDecideSell(zoo, rng, params)) return; + botDecideBuy(state, zoo, rng, params); +} + +/** + * Run income and decision ticks for all bot zoos. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {number} dt seconds since last tick + */ +export function tickBotZoos(state, nowUnix, dt) { + const zoos = state.worldZoos ?? []; + for (const zoo of zoos) { + if (zoo.id !== "player") { + ensureBotState(zoo, false); + tickBotIncome(zoo, dt); + tickBotDecisions(state, zoo, nowUnix); + } + } +} + +const PLAYER_AUTO_MIN_INTERVAL = 10; +const PLAYER_AUTO_MAX_INTERVAL = 28; + +/** + * Apply one upgrade (plot, skill, truck) for player auto mode. + * @param {import("./types.js").GameState} state + * @param {{ spendThreshold: number, upgradeChance: number }} params + * @param {() => number} rng + * @returns {void} + */ +function playerAutoDoOneUpgrade(state, params, rng) { + const { spendThreshold, upgradeChance } = params; + const plotCost = getPlotUpgradeCost(state.plotLevel ?? 1); + const plotMax = GameConfig.Plot?.MaxLevel ?? 8; + const skillCost = getConveyorUpgradeCost(state.conveyorLevel ?? 1); + const skillMax = GameConfig.Conveyor?.MaxLevel ?? 8; + const truckCost = getTruckUpgradeCost(state.truckLevel ?? 1); + const truckMax = (GameConfig.Truck && GameConfig.Truck.MaxLevel) ?? 5; + const canPlot = (state.plotLevel ?? 1) < plotMax && state.coins >= plotCost * spendThreshold; + const canSkill = (state.conveyorLevel ?? 1) < skillMax && state.coins >= skillCost * spendThreshold; + const canTruck = (state.truckLevel ?? 1) < truckMax && state.coins >= truckCost * spendThreshold; + const choices = []; + if (canPlot) choices.push("plot"); + if (canSkill) choices.push("skill"); + if (canTruck) choices.push("truck"); + if (choices.length === 0) return; + if (rng() >= upgradeChance) return; + const choice = choices[Math.floor(rng() * choices.length)]; + if (choice === "plot") tryUpgradePlot(state); + else if (choice === "skill") tryUpgrade(state); + else if (choice === "truck") tryUpgradeTruck(state); +} + +/** + * When auto mode is on, apply one bot-style upgrade decision for the player (plot, conveyor, or truck). + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {void} + */ +export function tickPlayerAutoMode(state, nowUnix) { + if (!state.autoMode) return; + const cfg = GameConfig.Bots || {}; + const minInterval = cfg.TickIntervalMinSeconds ?? PLAYER_AUTO_MIN_INTERVAL; + const maxInterval = cfg.TickIntervalMaxSeconds ?? PLAYER_AUTO_MAX_INTERVAL; + const last = state.lastPlayerAutoTickAt ?? 0; + if (nowUnix - last < minInterval) return; + const rng = () => Math.random(); + const nextInterval = minInterval + Math.floor(rng() * (maxInterval - minInterval + 1)); + if (nowUnix - last < nextInterval) return; + state.lastPlayerAutoTickAt = nowUnix; + const profileId = getEffectiveProfileId(state); + const params = getProfileParams(profileId); + playerAutoDoOneUpgrade(state, params, rng); +} diff --git a/web/js/config.js b/web/js/config.js new file mode 100644 index 0000000..550f473 --- /dev/null +++ b/web/js/config.js @@ -0,0 +1,236 @@ +/** @type {{ DataStoreName: string, StateVersion: number, IncomeTickMs: number, SaveIntervalMs: number, Plot: object, Conveyor: object, Mutation: object, Events: object, Visitor: object, Time: object, Weather: object, Quests: object, Prestige: object }} */ +export const GameConfig = { + DataStoreName: "BuildAZooWeb_v1", + StateVersion: 2, + /** Game loop interval (ms). Minimum 5000 to limit DB/API load. */ + IncomeTickMs: 5000, + /** Save to localStorage/API at most every this many ms. */ + SaveIntervalMs: 5000, + + Plot: { + BaseWidth: 6, + BaseHeight: 6, + MaxLevel: 8, + ExpandByLevel: 2, + BaseUpgradeCost: 300, + UpgradeGrowth: 1.7, + }, + + Conveyor: { + MaxLevel: 8, + BaseUpgradeCost: 250, + UpgradeGrowth: 1.65, + OfferCount: 3, + /** Min seconds between conveyor offer refresh. >= 5 to limit load. */ + RefreshSeconds: 8, + }, + + WorldMap: { + Zoos: [ + { id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: { Basic: 1, Ocean: 0, Mountain: 0 } }, + { id: "zoo_nord", name: "Zoo du Nord", x: 75, y: 15, animalWeights: { Basic: 2, Ocean: 1, Mountain: 1 } }, + { id: "zoo_est", name: "Zoo de l'Est", x: 85, y: 55, animalWeights: { Basic: 1, Ocean: 2, Mountain: 0 } }, + { id: "zoo_sud", name: "Zoo du Sud", x: 50, y: 85, animalWeights: { Basic: 1, Ocean: 1, Mountain: 2 } }, + { id: "zoo_ouest", name: "Zoo de l'Ouest", x: 15, y: 25, animalWeights: { Basic: 2, Ocean: 0, Mountain: 1 } }, + ], + Cities: [ + { id: "ville_nord", name: "Ville du Nord", x: 80, y: 10, maxVisitorsTowardZoos: 80 }, + { id: "ville_sud", name: "Ville du Sud", x: 45, y: 90, maxVisitorsTowardZoos: 80 }, + { id: "ville_centre", name: "Ville centrale", x: 50, y: 50, maxVisitorsTowardZoos: 100 }, + ], + Laboratory: { x: 50, y: 20, name: "Laboratoire" }, + TruckAnimationMs: 2500, + NpcTruckIntervalMs: 8000, + MapUpgrade: { + MaxLevel: 5, + BaseUpgradeCost: 350, + UpgradeGrowth: 1.5, + /** Cost in research points per level (agrandissement carte). */ + BaseResearchCost: 15, + ResearchUpgradeGrowth: 1.6, + }, + }, + + Bots: { + /** Max distance (in map %) to consider a zoo as neighbor for color weights. */ + NeighborMaxDistance: 35, + /** Min seconds between two decision ticks per bot. */ + TickIntervalMinSeconds: 8, + TickIntervalMaxSeconds: 25, + /** Initial coins range for new bots. */ + InitialCoinsMin: 150, + InitialCoinsMax: 450, + }, + + Truck: { + MaxLevel: 7, + BaseUpgradeCost: 400, + UpgradeGrowth: 1.5, + }, + + School: { + MaxLevel: 8, + BaseUpgradeCost: 250, + UpgradeGrowth: 1.65, + }, + + /** Centre de recherche (Rappel grandes règles): 7 niveaux, unités de recherche pour agrandir la carte, 10 zoos/unité. */ + Research: { + MaxLevel: 7, + ZoosPerUnit: 10, + BuildCost: 300, + BaseUpgradeCost: 320, + UpgradeGrowth: 1.6, + PointsPerTickPerLevel: 0.1, + }, + + /** Billeterie: 7 niveaux, 20 visiteurs/unité en simultané. */ + Billeterie: { + MaxLevel: 7, + VisitorsPerUnit: 20, + BuildCost: 280, + BaseUpgradeCost: 280, + UpgradeGrowth: 1.55, + }, + + /** Nourriture: 7 niveaux, 5 animaux/unité. */ + Food: { + MaxLevel: 7, + AnimalsPerUnit: 5, + BuildCost: 260, + BaseUpgradeCost: 260, + UpgradeGrowth: 1.5, + /** Seconds without being fed before the animal dies. */ + MaxSecondsWithoutFood: 120, + }, + + /** Accueil nouveaux animaux: 7 niveaux, 1 animal/unité. */ + Reception: { + MaxLevel: 7, + AnimalsPerUnit: 1, + BuildCost: 240, + BaseUpgradeCost: 240, + UpgradeGrowth: 1.55, + AcclimatationSecondsBase: 45, + /** Seconds a ready animal can wait without being placed before dying. */ + MaxSecondsReadyNotPlaced: 90, + }, + + /** Changement de milieu (couleur): 7 niveaux, payant. */ + BiomeChangeColor: { + MaxLevel: 7, + BuildCost: 340, + BaseUpgradeCost: 350, + UpgradeGrowth: 1.6, + }, + + /** Changement de milieu (température): 7 niveaux, payant. */ + BiomeChangeTemp: { + MaxLevel: 7, + BuildCost: 340, + BaseUpgradeCost: 350, + UpgradeGrowth: 1.6, + }, + + Mutation: { + BaseChance: 0.06, + }, + + Events: [], + + Visitor: { + BasePaymentPerVisitor: 0.15, + VisitorsPerAnimal: 1.2, + PlotLevelBonus: 0.1, + StagnationDecayAfterSeconds: 60, + StagnationDecayPerMinute: 0.05, + CityAttractionScale: 0.002, + AnimalValueScale: 0.00015, + /** Seconds without any visitor on the cell before the animal disappears. */ + MaxSecondsWithoutVisit: 300, + /** Multiplier bonus per souvenir shop level applied to payment per visitor (e.g. 0.2 = +20% per shop). */ + SouvenirShopBonusPerShop: 0.2, + /** Chance per visitor to be a luxury guest (0–1). */ + LuxuryGuestChance: 0.08, + /** Entry payment multiplier for luxury guests. */ + LuxuryEntryMultiplier: 3, + /** Extra shop spending multiplier for luxury guests (applied on top of normal shop bonus). */ + LuxuryShopMultiplier: 2.5, + /** Attractivity: penalty per recent death (subtracted from score). */ + AttractivityDeathPenalty: 0.5, + /** Attractivity: bonus per birth (added to score). */ + AttractivityBirthBonus: 0.2, + /** Extra stay time per souvenir shop level (e.g. 0.15 = +15% per level). Uses Time.DayLengthSeconds for base 1 day. */ + StayMultiplierPerShopLevel: 0.15, + /** Extra stay time per distinct animal species (e.g. 0.02 = +2% per species). */ + StayMultiplierPerSpecies: 0.02, + /** Incident (soif, poubelle, banc, animal loin, photo): base chance per visitor per tick when not in wait phase. */ + IncidentChanceBase: 0.002, + /** Multiplier to incident chance when in wait phase (truck, sale pending, etc.). */ + IncidentChanceWaitMultiplier: 4, + /** Seconds before unresolved incident: visitor leaves and attractivity penalty applied. */ + IncidentTimeoutSeconds: 45, + /** Attractivity bonus when player resolves an incident. */ + IncidentResolveAttractivityBonus: 0.15, + /** Coin bonus when player resolves an incident. */ + IncidentResolveCoinBonus: 8, + /** Attractivity penalty when incident times out unresolved. */ + IncidentUnresolvedAttractivityPenalty: 0.2, + }, + + Nursery: { + BuildCost: 200, + MaxLevel: 7, + BaseUpgradeCost: 180, + UpgradeGrowth: 1.5, + /** Seconds for a baby to become mature (divided by nursery level). */ + GrowthSecondsBase: 40, + /** Seconds a mature baby can wait without being placed before dying. */ + MaxSecondsMatureNotPlaced: 90, + }, + + /** Reproduction: delay between pair detection and baby birth; reduced by zoo score and biome/temp fit. */ + Reproduction: { + /** Base seconds until baby is born for an eligible pair. */ + BaseSeconds: 60, + /** Max Manhattan distance between blocks to count as adjacent (1 = edge-adjacent only). */ + MaxDistance: 1, + }, + + SouvenirShop: { + BuildCost: 250, + MaxLevel: 7, + BaseUpgradeCost: 220, + UpgradeGrowth: 1.55, + }, + + Time: { + DayLengthSeconds: 120, + PhaseShift: 0, + }, + + Weather: { + ChangeIntervalSeconds: 45, + RainChance: 0.25, + CloudyChance: 0.35, + }, + + Quests: { + CountPerDay: 3, + RewardBase: 50, + RewardPerLevel: 20, + }, + + Prestige: { + IncomeBonusPerLevel: 0.15, + MinCoinsToReset: 5000, + }, + + /** Phase 10: sale listings (baby/animal on truck → world map). Bébé invendu meurt après ce délai. */ + Sale: { + /** Seconds until a listing expires if not sold. After expiry, baby dies (deathCountRecent). */ + ListingDurationSeconds: 3600, + /** Default asking price for a baby or animal put on sale. */ + DefaultPrice: 50, + }, +}; diff --git a/web/js/conveyor.js b/web/js/conveyor.js new file mode 100644 index 0000000..25ebb9f --- /dev/null +++ b/web/js/conveyor.js @@ -0,0 +1,213 @@ +import { GameConfig } from "./config.js"; +import { LootTables, getAnimalToEggTypeMap } from "./loot-tables.js"; +import { defaultAnimalWeights } from "./state.js"; +import { getZooSkillLevel } from "./bot-zoo.js"; +import { getConveyorUpgradeCost, getSchoolUpgradeCost, getTruckUpgradeCost } from "./economy.js"; +import { pickId } from "./weighted-random.js"; +import { cellKey } from "./grid-utils.js"; + +const ANIMAL_TO_EGG_TYPE = getAnimalToEggTypeMap(); +const DEFAULT_ZOO_WEIGHTS = defaultAnimalWeights(); + +/** + * Skill level = max level among school cells, or state.conveyorLevel for backward compat. + * @param {import("./types.js").GameState} state + * @returns {number} + */ +export function getSkillLevel(state) { + let maxSchool = 0; + for (const cell of Object.values(state.grid.cells)) { + if (cell.kind === "school") maxSchool = Math.max(maxSchool, cell.level); + } + return maxSchool || state.conveyorLevel || 1; +} + +/** + * @param {number} conveyorLevel + * @returns {Array<{ id: string, weight: number }>} + */ +function getEligibleEggTypes(conveyorLevel) { + const entries = []; + for (const [eggType, def] of Object.entries(LootTables.EggTypes)) { + if (conveyorLevel >= def.minConveyorLevel) + entries.push({ id: eggType, weight: 100 - def.minConveyorLevel * 8 }); + } + return entries; +} + +/** + * Player zoo weights from grid: more animals of a type => more of that egg type at own zoo. + * @param {import("./types.js").GameState} state + * @returns {Record} + */ +export function getPlayerZooWeights(state) { + const colorKeys = Object.keys(LootTables.EggTypes); + const w = Object.fromEntries(colorKeys.map((k) => [k, 0])); + for (const cell of Object.values(state.grid.cells)) { + if (cell.kind === "animal") { + const eggType = ANIMAL_TO_EGG_TYPE[cell.id]; + if (eggType) w[eggType] = (w[eggType] ?? 0) + 1; + } + } + return w; +} + +/** + * Zoos that can offer this egg type (skill level allows it and zoo has weight for it). + * @param {import("./types.js").GameState} state + * @param {string} eggType + * @returns {Array<{ id: string, weight: number }>} + */ +function getZoosForEggType(state, eggType) { + const zoos = state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: DEFAULT_ZOO_WEIGHTS }]; + const eggDef = LootTables.EggTypes[eggType]; + const minLevel = eggDef ? eggDef.minConveyorLevel : 1; + const playerWeights = getPlayerZooWeights(state); + const entries = []; + for (const zoo of zoos) { + const skillLevel = zoo.id === "player" ? getSkillLevel(state) : getZooSkillLevel(zoo); + if (skillLevel >= minLevel) { + const weights = zoo.id === "player" ? playerWeights : (zoo.animalWeights ?? {}); + const w = weights[eggType] ?? 0; + if (w > 0) entries.push({ id: zoo.id, weight: w }); + } + } + if (entries.length === 0) entries.push({ id: "player", weight: 1 }); + return entries; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function refreshOffers(state, nowUnix) { + const rng = () => Math.random(); + const skillLevel = getSkillLevel(state); + const pool = getEligibleEggTypes(skillLevel); + const offers = []; + for (let i = 0; i < GameConfig.Conveyor.OfferCount; i++) { + const eggType = pickId(rng, pool); + const eggDef = LootTables.EggTypes[eggType]; + const zooPool = getZoosForEggType(state, eggType); + const zooId = zooPool.length ? pickId(rng, zooPool) : "player"; + offers.push({ eggType, price: eggDef.price, zooId }); + } + const animalIds = Object.keys(LootTables.Animals ?? {}); + if (animalIds.length > 0) { + const babyAnimalId = animalIds[Math.floor(rng() * animalIds.length)]; + const babyDef = LootTables.Animals[babyAnimalId]; + const babyPrice = babyDef ? Math.floor(50 + (babyDef.rarityLevel ?? 1) * 30) : 80; + offers.push({ type: "baby", animalId: babyAnimalId, price: babyPrice, zooId: "player" }); + const adultAnimalId = animalIds[Math.floor(rng() * animalIds.length)]; + const adultDef = LootTables.Animals[adultAnimalId]; + const adultPrice = adultDef ? Math.floor(80 + (adultDef.rarityLevel ?? 1) * 40) : 120; + offers.push({ type: "animal", animalId: adultAnimalId, price: adultPrice, zooId: "player" }); + } + state.conveyorOffers = offers; + state.lastOfferRefreshAt = nowUnix; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {boolean} + */ +export function shouldRefresh(state, nowUnix) { + return nowUnix - state.lastOfferRefreshAt >= GameConfig.Conveyor.RefreshSeconds; +} + +/** + * Upgrade school at cell (x,y). Returns [ok, reason]. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, string?]} + */ +export function tryUpgradeSchool(state, x, y) { + const key = cellKey(x, y); + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== "school") return [false, "NoSchool"]; + const maxLevel = (GameConfig.School && GameConfig.School.MaxLevel) || GameConfig.Conveyor.MaxLevel; + if (cell.level >= maxLevel) return [false, "ConveyorMaxLevel"]; + const cost = getSchoolUpgradeCost(cell.level); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + cell.level += 1; + state.conveyorLevel = getSkillLevel(state); + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + if (state.stats) state.stats.conveyorUpgrades = (state.stats.conveyorUpgrades ?? 0) + 1; + return [true, undefined]; +} + +/** + * Upgrade truck. Returns [ok, reason]. + * @param {import("./types.js").GameState} state + * @returns {[boolean, string?]} + */ +export function tryUpgradeTruck(state) { + const level = state.truckLevel ?? 1; + const maxLevel = (GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5; + if (level >= maxLevel) return [false, "TruckMaxLevel"]; + const cost = getTruckUpgradeCost(level); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + state.truckLevel = level + 1; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + if (state.stats) state.stats.truckUpgrades = (state.stats.truckUpgrades ?? 0) + 1; + return [true, undefined]; +} + +/** + * @param {import("./types.js").GameState} state + * @returns {[boolean, string?]} + */ +export function tryUpgrade(state) { + if (state.conveyorLevel >= GameConfig.Conveyor.MaxLevel) return [false, "ConveyorMaxLevel"]; + const cost = getConveyorUpgradeCost(state.conveyorLevel); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + state.conveyorLevel += 1; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + if (state.stats) state.stats.conveyorUpgrades = (state.stats.conveyorUpgrades ?? 0) + 1; + return [true, undefined]; +} + +/** + * @param {import("./types.js").GameState} state + * @param {string} eggType + * @returns {{ eggType: string, price: number, zooId?: string } | null} + */ +export function findOffer(state, eggType) { + return state.conveyorOffers.find((o) => o.eggType === eggType) ?? null; +} + +/** + * @param {import("./types.js").GameState} state + * @param {string} [animalId] + * @returns {{ type: "baby", animalId: string, price: number, zooId?: string } | null} + */ +export function findBabyOffer(state, animalId) { + const o = state.conveyorOffers.find((x) => x.type === "baby" && (animalId === null || animalId === undefined || x.animalId === animalId)); + return o && o.type === "baby" ? o : null; +} + +/** + * @param {import("./types.js").GameState} state + * @param {string} [animalId] + * @returns {{ type: "animal", animalId: string, price: number, zooId?: string } | null} + */ +export function findAnimalOffer(state, animalId) { + const o = state.conveyorOffers.find((x) => x.type === "animal" && (animalId === null || animalId === undefined || x.animalId === animalId)); + return o && o.type === "animal" ? o : null; +} + +/** + * Pick another zoo (not player) for truck sale animation. + * @param {import("./types.js").GameState} state + * @returns {string} + */ +export function pickSaleTargetZoo(state) { + const zoos = (state.worldZoos ?? []).filter((z) => z.id !== "player"); + if (zoos.length === 0) return "player"; + return zoos[Math.floor(Math.random() * zoos.length)].id; +} diff --git a/web/js/default-grid-layout.js b/web/js/default-grid-layout.js new file mode 100644 index 0000000..ac3c7fa --- /dev/null +++ b/web/js/default-grid-layout.js @@ -0,0 +1,66 @@ +/** + * Shared default zoo grid layout: row 1 building cells and 3 starter couples (row 2). + * Used by state.js (defaultState) and prestige.js (doPrestige). + */ + +import { LootTables } from "./loot-tables.js"; +import { fillAnimalBlock } from "./placement.js"; + +/** Row 1 layout: (col, kind). Columns 1–6. */ +const ROW1_LAYOUT = [ + [1, "research"], + [2, "billeterie"], + [3, "nursery"], + [4, "reception"], + [5, "food"], + [6, "school"], +]; + +/** + * Build default cells for row 1 (research, billeterie, nursery, reception, food, school). + * @returns {Record} + */ +export function buildDefaultRow1Cells() { + const cells = {}; + for (const [col, kind] of ROW1_LAYOUT) { + cells[`${col}_1`] = { kind, level: 1 }; + } + return cells; +} + +/** Default animal ids for the 3 starter couples (one per biome: Meadow, Ocean, Mountain). */ +export const STARTER_ANIMAL_IDS_BY_BIOME = ["c0_r0", "c5_r0", "c10_r0"]; + +/** Positions for the 6 starter animals (row 2, columns 1–6). */ +export const STARTER_ANIMAL_POSITIONS = [[1, 2], [2, 2], [3, 2], [4, 2], [5, 2], [6, 2]]; + +/** + * Place 3 breeding couples (6 animals) on the grid. Mutates state.grid.cells. + * @param {import("./types.js").GameState} state + */ +export function addStarterAnimals(state) { + const now = Math.floor(Date.now() / 1000); + let idx = 0; + for (const animalId of STARTER_ANIMAL_IDS_BY_BIOME) { + const def = LootTables.Animals[animalId]; + if (def !== null && def !== undefined) { + const w = def.cellsWide ?? 1; + const h = def.cellsHigh ?? 1; + for (let pair = 0; pair < 2 && idx < STARTER_ANIMAL_POSITIONS.length; pair++, idx++) { + const [x, y] = STARTER_ANIMAL_POSITIONS[idx]; + fillAnimalBlock(state, x, y, { + kind: "animal", + id: animalId, + mutation: "none", + level: 1, + placedAt: now, + lastVisitedAt: now, + lastFedAt: now, + cellsWide: w, + cellsHigh: h, + fromOtherZoo: false, + }); + } + } + } +} diff --git a/web/js/economy.js b/web/js/economy.js new file mode 100644 index 0000000..1bfd136 --- /dev/null +++ b/web/js/economy.js @@ -0,0 +1,306 @@ +import { GameConfig } from "./config.js"; + +/** + * @param {number} baseCost + * @param {number} growth + * @param {number} level + * @returns {number} + */ +export function exponentialCost(baseCost, growth, level) { + const exponent = Math.max(0, level - 1); + return Math.floor(baseCost * Math.pow(growth, exponent) + 0.5); +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getConveyorUpgradeCost(currentLevel) { + return exponentialCost( + GameConfig.Conveyor.BaseUpgradeCost, + GameConfig.Conveyor.UpgradeGrowth, + currentLevel + ); +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getTruckUpgradeCost(currentLevel) { + return exponentialCost( + GameConfig.Truck.BaseUpgradeCost, + GameConfig.Truck.UpgradeGrowth, + currentLevel + ); +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getSchoolUpgradeCost(currentLevel) { + return exponentialCost( + GameConfig.School.BaseUpgradeCost, + GameConfig.School.UpgradeGrowth, + currentLevel + ); +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getWorldMapUpgradeCost(currentLevel) { + const cfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * Research points required to upgrade world map level (phase 9: agrandissement en unités de recherche). + * @param {number} currentLevel + * @returns {number} + */ +export function getWorldMapUpgradeResearchCost(currentLevel) { + const cfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade; + if (!cfg || cfg.BaseResearchCost === null || cfg.BaseResearchCost === undefined) return 9999; + const growth = cfg.ResearchUpgradeGrowth ?? 1.6; + return exponentialCost(cfg.BaseResearchCost, growth, currentLevel); +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getPlotUpgradeCost(currentLevel) { + return exponentialCost( + GameConfig.Plot.BaseUpgradeCost, + GameConfig.Plot.UpgradeGrowth, + currentLevel + ); +} + +/** + * @returns {number} + */ +export function getNurseryBuildCost() { + return GameConfig.Nursery?.BuildCost ?? 200; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getNurseryUpgradeCost(currentLevel) { + const cfg = GameConfig.Nursery; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * @returns {number} + */ +export function getSouvenirShopBuildCost() { + return GameConfig.SouvenirShop?.BuildCost ?? 250; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getSouvenirShopUpgradeCost(currentLevel) { + const cfg = GameConfig.SouvenirShop; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * @returns {number} + */ +export function getResearchBuildCost() { + return GameConfig.Research?.BuildCost ?? 300; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getResearchUpgradeCost(currentLevel) { + const cfg = GameConfig.Research; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * @returns {number} + */ +export function getBilleterieBuildCost() { + return GameConfig.Billeterie?.BuildCost ?? 280; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getBilleterieUpgradeCost(currentLevel) { + const cfg = GameConfig.Billeterie; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * @returns {number} + */ +export function getFoodBuildCost() { + return GameConfig.Food?.BuildCost ?? 260; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getFoodUpgradeCost(currentLevel) { + const cfg = GameConfig.Food; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * @returns {number} + */ +export function getReceptionBuildCost() { + return GameConfig.Reception?.BuildCost ?? 240; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getReceptionUpgradeCost(currentLevel) { + const cfg = GameConfig.Reception; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * @returns {number} + */ +export function getBiomeChangeColorBuildCost() { + return GameConfig.BiomeChangeColor?.BuildCost ?? 340; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getBiomeChangeColorUpgradeCost(currentLevel) { + const cfg = GameConfig.BiomeChangeColor; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** + * @returns {number} + */ +export function getBiomeChangeTempBuildCost() { + return GameConfig.BiomeChangeTemp?.BuildCost ?? 340; +} + +/** + * @param {number} currentLevel + * @returns {number} + */ +export function getBiomeChangeTempUpgradeCost(currentLevel) { + const cfg = GameConfig.BiomeChangeTemp; + if (!cfg) return 9999; + return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel); +} + +/** Building kinds that use the same build/upgrade pattern (7 levels). */ +export const BUILDING_KINDS = [ + "research", + "billeterie", + "food", + "reception", + "biomeChangeColor", + "biomeChangeTemp", +]; + +/** + * @param {typeof BUILDING_KINDS[number]} kind + * @returns {number} + */ +export function getBuildingBuildCost(kind) { + switch (kind) { + case "research": + return getResearchBuildCost(); + case "billeterie": + return getBilleterieBuildCost(); + case "food": + return getFoodBuildCost(); + case "reception": + return getReceptionBuildCost(); + case "biomeChangeColor": + return getBiomeChangeColorBuildCost(); + case "biomeChangeTemp": + return getBiomeChangeTempBuildCost(); + default: + return 9999; + } +} + +/** + * @param {typeof BUILDING_KINDS[number]} kind + * @param {number} currentLevel + * @returns {number} + */ +export function getBuildingUpgradeCost(kind, currentLevel) { + switch (kind) { + case "research": + return getResearchUpgradeCost(currentLevel); + case "billeterie": + return getBilleterieUpgradeCost(currentLevel); + case "food": + return getFoodUpgradeCost(currentLevel); + case "reception": + return getReceptionUpgradeCost(currentLevel); + case "biomeChangeColor": + return getBiomeChangeColorUpgradeCost(currentLevel); + case "biomeChangeTemp": + return getBiomeChangeTempUpgradeCost(currentLevel); + default: + return 9999; + } +} + +/** + * @param {typeof BUILDING_KINDS[number]} kind + * @returns {number} + */ +export function getBuildingMaxLevel(kind) { + const key = kind.charAt(0).toUpperCase() + kind.slice(1); + const cfg = GameConfig[key]; + return cfg?.MaxLevel ?? 7; +} + +/** + * @param {number} level + * @returns {number} + */ +export function getLevelMultiplier(level) { + const current = level ?? 1; + return 1 + 0.1 * Math.max(0, current - 1); +} + +/** + * @param {number} baseIncomePerSecond + * @param {number} level + * @param {number} mutationMultiplier + * @param {number} sellFactor + * @returns {number} + */ +export function getSellValue(baseIncomePerSecond, level, mutationMultiplier, sellFactor) { + const levelMult = getLevelMultiplier(level); + return Math.floor(baseIncomePerSecond * mutationMultiplier * levelMult * sellFactor + 0.5); +} diff --git a/web/js/event-service.js b/web/js/event-service.js new file mode 100644 index 0000000..9dad7f7 --- /dev/null +++ b/web/js/event-service.js @@ -0,0 +1,7 @@ +/** + * @param {number} _nowUnix + * @returns {{ incomeMultiplier: number, mutationBonus: number }} + */ +export function getActiveModifiers(_nowUnix) { + return { incomeMultiplier: 1, mutationBonus: 0 }; +} diff --git a/web/js/food.js b/web/js/food.js new file mode 100644 index 0000000..a1caa7c --- /dev/null +++ b/web/js/food.js @@ -0,0 +1,213 @@ +/** + * Food capacity and feeding tick. Animals are fed up to capacity each tick; + * unfed animals accumulate time without food and are removed by checkDeathCauses. + */ + +import { GameConfig } from "./config.js"; +import { LootTables } from "./loot-tables.js"; +import { isOriginCell } from "./grid-utils.js"; +import { getBlockKeysFromCell } from "./placement.js"; +import { getDisplayBiome, getDisplayTemperature, isAnimalAllowedOnBiome } from "./biome-rules.js"; + +/** + * Total food capacity = sum over food cells of (level × AnimalsPerUnit). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +export function getFoodCapacity(state) { + const cfg = GameConfig.Food; + if (!cfg) return 0; + const unit = cfg.AnimalsPerUnit ?? 5; + let total = 0; + for (const cell of Object.values(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "food") { + total += (cell.level ?? 1) * unit; + } + } + return total; +} + +/** + * Count origin animal cells (each animal block counts once). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +export function getOriginAnimalCount(state) { + let n = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) n += 1; + } + return n; +} + +/** + * Feed up to `capacity` animals this tick. Animals with oldest lastFedAt are fed first. + * Sets lastFedAt = nowUnix on each fed animal (all cells of the block). + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function tickFeeding(state, nowUnix) { + const capacity = getFoodCapacity(state); + if (capacity <= 0) return; + + const originAnimals = []; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) { + const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix; + originAnimals.push({ key, cell, lastFed }); + } + } + originAnimals.sort((a, b) => a.lastFed - b.lastFed); + + let fed = 0; + for (const { key, cell } of originAnimals) { + if (fed >= capacity) break; + const m = key.match(/^(\d+)_(\d+)$/); + if (m) { + setBlockLastFedAt(state, { + ox: Number(m[1]), + oy: Number(m[2]), + w: cell.cellsWide ?? 1, + h: cell.cellsHigh ?? 1, + nowUnix, + }); + fed += 1; + } + } +} + +function setBlockLastFedAt(state, opts) { + const { ox, oy, w, h, nowUnix } = opts; + for (let dy = 0; dy < h; dy++) { + for (let dx = 0; dx < w; dx++) { + const k = `${ox + dx}_${oy + dy}`; + const c = state.grid.cells[k]; + if (c && c.kind === "animal") c.lastFedAt = nowUnix; + } + } +} + +/** + * Compute feeding rate = ratio of animals that were fed this period (instantaneous: + * fed count / total origin count). Call after tickFeeding; store in state.feedingRate for display. + * @param {import("./types.js").GameState} state + * @param {number} _nowUnix + * @returns {number} 0..1 + */ +export function getFeedingRate(state, _nowUnix) { + const total = getOriginAnimalCount(state); + if (total <= 0) return 1; + const capacity = getFoodCapacity(state); + const fed = Math.min(total, capacity); + return fed / total; +} + +/** + * Remove animals and entities that meet death conditions. Increments state.deathCountRecent. + * Causes: not visited, not fed, temperature out of range, biome not allowed, + * baby mature not placed in time, reception animal ready not placed in time. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function checkDeathCauses(state, nowUnix) { + const maxVisit = GameConfig.Visitor?.MaxSecondsWithoutVisit ?? 300; + const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120; + const maxMatureNotPlaced = GameConfig.Nursery?.MaxSecondsMatureNotPlaced ?? 90; + const maxReadyNotPlaced = GameConfig.Reception?.MaxSecondsReadyNotPlaced ?? 90; + const grid = state.grid; + const cells = grid.cells; + + const blocksToRemove = collectAnimalDeathBlocks({ state, grid, cells, nowUnix, maxVisit, maxFood }); + for (const { ox, oy } of blocksToRemove) { + const blockKeys = getBlockKeysFromCell(state, ox, oy); + for (const k of blockKeys) delete cells[k]; + state.deathCountRecent = (state.deathCountRecent ?? 0) + 1; + } + + const babiesRemoved = filterPendingBabies(state, nowUnix, maxMatureNotPlaced); + if (babiesRemoved > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babiesRemoved; + + const receptionRemoved = filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced); + if (receptionRemoved > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + receptionRemoved; +} + +/** + * @param {{ state: import("./types.js").GameState, grid: { width: number, height: number }, cells: Record, nowUnix: number, maxVisit: number, maxFood: number }} opts + * @returns {Array<{ ox: number, oy: number }>} + */ +function collectAnimalDeathBlocks(opts) { + const { grid, cells, nowUnix, maxVisit, maxFood } = opts; + const blocksToRemove = []; + for (const [key, cell] of Object.entries(cells)) { + if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) { + // skip + } else { + const def = LootTables.Animals[cell.id]; + if (def !== null && def !== undefined) { + const entry = maybeDeathBlock({ key, cell, grid, nowUnix, maxVisit, maxFood, def }); + if (entry) blocksToRemove.push(entry); + } + } + } + return blocksToRemove; +} + +function maybeDeathBlock(opts) { + const { key, cell, grid, nowUnix, maxVisit, maxFood, def } = opts; + const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix; + const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix; + const m = key.match(/^(\d+)_(\d+)$/); + if (!m) return null; + const ox = Number(m[1]); + const oy = Number(m[2]); + const cellBiome = getDisplayBiome(ox, oy, grid); + const cellTemp = getDisplayTemperature(ox, oy, grid); + const idealTemp = def.idealTemperature ?? 18; + const tolerance = def.temperatureTolerance ?? 5; + const tempOk = Math.abs(cellTemp - idealTemp) <= tolerance; + const biomeOk = isAnimalAllowedOnBiome(def.biome, cellBiome); + const visitedOk = nowUnix - lastVisited < maxVisit; + const fedOk = nowUnix - lastFed < maxFood; + if (!visitedOk || !fedOk || !tempOk || !biomeOk) return { ox, oy }; + return null; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {number} maxMatureNotPlaced + * @returns {number} + */ +function filterPendingBabies(state, nowUnix, maxMatureNotPlaced) { + const pendingBabies = state.pendingBabies ?? []; + let removed = 0; + state.pendingBabies = pendingBabies.filter((p) => { + if (nowUnix <= p.readyAt) return true; + if (nowUnix - p.readyAt >= maxMatureNotPlaced) { + removed += 1; + return false; + } + return true; + }); + return removed; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {number} maxReadyNotPlaced + * @returns {number} + */ +function filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced) { + const receptionAnimals = state.receptionAnimals ?? []; + let removed = 0; + state.receptionAnimals = receptionAnimals.filter((r) => { + if (nowUnix <= r.readyAt) return true; + if (nowUnix - r.readyAt >= maxReadyNotPlaced) { + removed += 1; + return false; + } + return true; + }); + return removed; +} diff --git a/web/js/game-loop.js b/web/js/game-loop.js new file mode 100644 index 0000000..0a6fe00 --- /dev/null +++ b/web/js/game-loop.js @@ -0,0 +1,111 @@ +import { GameConfig } from "./config.js"; +import { refreshOffers, shouldRefresh } from "./conveyor.js"; +import { run as runHatching } from "./hatching.js"; +import { tick as incomeTick, tickVisitorArrivals, getAttractivityScore } from "./income.js"; +import { getActiveModifiers } from "./event-service.js"; +import { tickTime, tickWeather } from "./time-weather.js"; +import { tickQuests } from "./quests.js"; +import { saveState } from "./state.js"; +import { playSound } from "./audio.js"; +import { pruneTruckSales, addNpcTruckSale, shouldAddNpcTruck, tickLaboratory } from "./world-map.js"; +import { tickAnimalVisits } from "./animal-visits.js"; +import { tickPlayerAutoMode } from "./bot-zoo.js"; +import { tickFeeding, checkDeathCauses, getFeedingRate } from "./food.js"; +import { tickReproduction, getReproductionScore } from "./reproduction.js"; +import { tickVisitorIncidents } from "./visitor-incidents.js"; +import { tickSaleListings } from "./trade.js"; + +/** + * Add research points from all research cells. PointsPerTickPerLevel * level per second. + * @param {import("./types.js").GameState} state + * @param {number} dt + * @returns {void} + */ +function tickResearch(state, dt) { + const cfg = GameConfig.Research; + if (!cfg || cfg.PointsPerTickPerLevel === null || cfg.PointsPerTickPerLevel === undefined) return; + const pointsPerLevelPerSecond = cfg.PointsPerTickPerLevel; + let total = 0; + for (const [, cell] of Object.entries(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "research") { + const level = cell.level ?? 1; + total += pointsPerLevelPerSecond * level * dt; + } + } + if (total > 0) { + state.researchPoints = (state.researchPoints ?? 0) + total; + } +} + +/** + * Run one simulation tick: time, feeding, reproduction, visitors, income, research, hatching, quests. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {number} nowMs + * @param {number} dt + * @returns {{ hatched: Array<{ x: number, y: number }>, questEarned: number }} + */ +function doOneTick(state, nowUnix, nowMs, dt) { + if (shouldRefresh(state, nowUnix)) refreshOffers(state, nowUnix); + pruneTruckSales(state, nowMs); + const eventModifiers = getActiveModifiers(nowUnix); + tickTime(state, dt); + tickWeather(state, nowUnix); + tickAnimalVisits(state, nowUnix, nowMs); + if (state.autoMode) tickPlayerAutoMode(state, nowUnix); + tickFeeding(state, nowUnix); + state.feedingRate = getFeedingRate(state, nowUnix); + checkDeathCauses(state, nowUnix); + tickReproduction(state, nowUnix); + state.reproductionScore = getReproductionScore(state); + tickSaleListings(state, nowUnix); + tickVisitorArrivals(state, nowUnix); + tickVisitorIncidents(state, nowUnix); + incomeTick(state, dt, eventModifiers); + tickResearch(state, dt); + state.attractivityScore = getAttractivityScore(state); + const { hatched } = runHatching(state, nowUnix, eventModifiers); + const questEarned = tickQuests(state); + return { hatched, questEarned }; +} + +/** + * @param {() => import("./types.js").GameState} getState + * @param {(state: import("./types.js").GameState, payload: { lastHatched?: Array<{ x: number, y: number }> }) => void} onUpdate + * @param {(state: import("./types.js").GameState) => void} [saveStateFn] + * @returns {void} + */ +export function startGameLoop(getState, onUpdate, saveStateFn) { + const save = saveStateFn || saveState; + let lastWall = performance.now() / 1000; + let saveAccum = 0; + let lastNpcTruckAt = 0; + + function loop() { + const state = getState(); + const nowWall = performance.now() / 1000; + const dt = Math.min(nowWall - lastWall, 2); + lastWall = nowWall; + const nowUnix = Math.floor(Date.now() / 1000); + const nowMs = Date.now(); + + if (shouldAddNpcTruck(nowMs, lastNpcTruckAt)) { + addNpcTruckSale(state, nowMs); + lastNpcTruckAt = nowMs; + } + tickLaboratory(state, nowUnix); + const { hatched, questEarned } = doOneTick(state, nowUnix, nowMs, dt); + if (questEarned > 0) playSound("quest"); + onUpdate(state, { lastHatched: hatched }); + + saveAccum += dt; + if (saveAccum >= GameConfig.SaveIntervalMs / 1000) { + saveAccum = 0; + save(state); + } + } + + const intervalMs = Math.max(100, GameConfig.IncomeTickMs); + setInterval(loop, intervalMs); + loop(); +} diff --git a/web/js/grid-utils.js b/web/js/grid-utils.js new file mode 100644 index 0000000..a48270d --- /dev/null +++ b/web/js/grid-utils.js @@ -0,0 +1,62 @@ +import { GameConfig } from "./config.js"; + +/** + * @param {number} x + * @param {number} y + * @returns {string} + */ +export function cellKey(x, y) { + return `${x}_${y}`; +} + +/** + * @param {number} plotLevel + * @returns {[number, number]} + */ +export function plotSizeFromLevel(plotLevel) { + const level = Math.max(1, Math.min(GameConfig.Plot.MaxLevel, plotLevel)); + const extra = (level - 1) * GameConfig.Plot.ExpandByLevel; + return [ + GameConfig.Plot.BaseWidth + extra, + GameConfig.Plot.BaseHeight + extra, + ]; +} + +/** + * @param {number} width + * @param {number} height + * @param {number} x + * @param {number} y + * @returns {boolean} + */ +export function withinBounds(width, height, x, y) { + return x >= 1 && y >= 1 && x <= width && y <= height; +} + +/** + * Keys for a rectangular block (origin = top-left). All coordinates 1-based. + * @param {number} originX + * @param {number} originY + * @param {number} w + * @param {number} h + * @returns {string[]} + */ +export function getBlockKeys(originX, originY, w, h) { + const keys = []; + for (let dy = 0; dy < h; dy++) { + for (let dx = 0; dx < w; dx++) { + keys.push(cellKey(originX + dx, originY + dy)); + } + } + return keys; +} + +/** + * @param {string} key "x_y" + * @param {import("./types.js").Cell} cell + * @returns {boolean} + */ +export function isOriginCell(key, cell) { + if (cell === null || cell === undefined || cell.kind !== "animal") return false; + return cell.originKey === null || cell.originKey === undefined || cell.originKey === key; +} diff --git a/web/js/hatching.js b/web/js/hatching.js new file mode 100644 index 0000000..d009c3a --- /dev/null +++ b/web/js/hatching.js @@ -0,0 +1,112 @@ +import { GameConfig } from "./config.js"; +import { LootTables } from "./loot-tables.js"; +import { getMutationEntries, getIncomeMultiplier } from "./mutation-rules.js"; +import { getCellBiome, getBiomesCompatibleWithCell } from "./biome-rules.js"; +import { cellKey } from "./grid-utils.js"; +import { fillAnimalBlock, canPlaceMultiCell } from "./placement.js"; +import { createSeededRng, pickId } from "./weighted-random.js"; + +const BIOME_TO_EGG_TYPE = { Meadow: "Color_1", Ocean: "Color_6", Mountain: "Color_11", Forest: "Color_1", Freshwater: "Color_6" }; + +/** + * Loot entries for animals that match the cell biome. If the egg type has none, use the egg type for that biome. + * @param {string} cellBiome + * @param {Array<{ id: string, weight: number }>} loot + * @returns {Array<{ id: string, weight: number }>} + */ +function lootForBiome(cellBiome, loot) { + const allowedBiomes = getBiomesCompatibleWithCell(cellBiome); + const allowed = loot.filter((entry) => { + const def = LootTables.Animals[entry.id]; + return def && allowedBiomes.includes(def.biome); + }); + if (allowed.length > 0) return allowed; + const eggType = BIOME_TO_EGG_TYPE[cellBiome]; + const fallbackDef = eggType ? LootTables.EggTypes[eggType] : null; + return fallbackDef ? fallbackDef.loot : loot; +} + +/** + * @param {string} animalId + * @param {string} mutationId + * @param {number} nowUnix + * @param {{ cellsWide?: number, cellsHigh?: number }} dimensions + * @returns {import("./types.js").AnimalCell} + */ +function buildAnimalCell(animalId, mutationId, nowUnix, dimensions = {}) { + return { + kind: "animal", + id: animalId, + mutation: mutationId, + level: 1, + placedAt: nowUnix, + lastVisitedAt: nowUnix, + lastFedAt: nowUnix, + ...dimensions, + }; +} + +/** + * @param {import("./types.js").GameState} state + * @param {{ x: number, y: number, nowUnix: number, eventModifiers: { incomeMultiplier: number, mutationBonus: number } }} opts + * @returns {boolean} + */ +export function tryHatchCell(state, opts) { + const { x, y, nowUnix, eventModifiers } = opts; + const key = cellKey(x, y); + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== "egg") return false; + if (nowUnix < cell.hatchAt) return false; + + const eggDef = LootTables.EggTypes[cell.eggType]; + if (eggDef === null || eggDef === undefined) throw new Error("HatchingService: unknown egg type"); + + const cellBiome = getCellBiome(state.grid.width, state.grid.height, x, y); + const loot = lootForBiome(cellBiome, eggDef.loot); + if (loot.length === 0) return false; + + const rng = createSeededRng(cell.seed); + const pickedAnimalId = pickId(rng, loot); + const animalDef = LootTables.Animals[pickedAnimalId]; + if (animalDef === null || animalDef === undefined) return false; + + const mutationChance = GameConfig.Mutation.BaseChance + eventModifiers.mutationBonus; + let mutationId = "none"; + if (rng() < mutationChance) mutationId = pickId(rng, getMutationEntries()); + if (getIncomeMultiplier(mutationId) === undefined) mutationId = "none"; + + const w = animalDef.cellsWide ?? 1; + const h = animalDef.cellsHigh ?? 1; + const [canPlace, _reason] = canPlaceMultiCell(state, { originX: x, originY: y, w, h, excludeOriginKey: key }); + if (!canPlace) return false; + const animalData = buildAnimalCell(pickedAnimalId, mutationId, nowUnix, { + cellsWide: w, + cellsHigh: h, + }); + fillAnimalBlock(state, x, y, animalData); + return true; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {{ incomeMultiplier: number, mutationBonus: number }} eventModifiers + * @returns {{ changed: boolean, hatched: Array<{ x: number, y: number }> }} + */ +export function run(state, nowUnix, eventModifiers) { + const hatched = []; + const keysToProcess = []; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell.kind === "egg" && nowUnix >= cell.hatchAt) keysToProcess.push(key); + } + for (const key of keysToProcess) { + const m = key.match(/^(\d+)_(\d+)$/); + if (m) { + const x = Number(m[1]); + const y = Number(m[2]); + const didHatch = tryHatchCell(state, { x, y, nowUnix, eventModifiers }); + if (didHatch && state.grid.cells[cellKey(x, y)]?.kind === "animal") hatched.push({ x, y }); + } + } + return { changed: hatched.length > 0, hatched }; +} diff --git a/web/js/income.js b/web/js/income.js new file mode 100644 index 0000000..68e0047 --- /dev/null +++ b/web/js/income.js @@ -0,0 +1,288 @@ +import { LootTables } from "./loot-tables.js"; +import { getIncomeMultiplier } from "./mutation-rules.js"; +import { getLevelMultiplier, getSellValue } from "./economy.js"; +import { GameConfig } from "./config.js"; +import { getPrestigeIncomeMultiplier } from "./prestige.js"; +import { isOriginCell } from "./grid-utils.js"; +import { getOriginAnimalCount } from "./food.js"; + +/** + * Total sell value of all animals in the zoo (used for visitor attraction). Counts each animal block once (origin cell only). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getTotalAnimalValue(state) { + let total = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell.kind !== "animal" || !isOriginCell(key, cell)) { + // skip non-origin animals + } else { + const animalDef = LootTables.Animals[cell.id]; + if (animalDef !== null && animalDef !== undefined) { + const mutationMult = getIncomeMultiplier(cell.mutation); + total += getSellValue( + animalDef.baseIncomePerSecond, + cell.level, + mutationMult, + animalDef.sellFactor + ); + } + } + } + return total; +} + +/** + * Max simultaneous visitors allowed by billeterie capacity. Entry is only via billeterie. + * @param {import("./types.js").GameState} state + * @returns {number} + */ +export function getBilleterieCapacity(state) { + const cfg = GameConfig.Billeterie; + if (!cfg) return 0; + const unit = cfg.VisitorsPerUnit ?? 20; + let total = 0; + for (const cell of Object.values(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "billeterie") { + total += (cell.level ?? 1) * unit; + } + } + return total; +} + +/** + * Attraction from cities: per-city contribution = min(maxVisitorsTowardZoos, rawWeight * 100), summed then scaled. + * Closer cities contribute more, but each city is capped by maxVisitorsTowardZoos. + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getCityAttraction(state) { + const cities = GameConfig.WorldMap?.Cities; + if (!cities || cities.length === 0) return 0; + const zoos = state.worldZoos ?? []; + const player = zoos.find((z) => z.id === "player"); + if (!player) return 0; + const scale = GameConfig.Visitor.CityAttractionScale ?? 0.002; + const rawMultiplier = 100; + let sum = 0; + for (const city of cities) { + const dx = (city.x - player.x) / 100; + const dy = (city.y - player.y) / 100; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + const raw = 1 / (1 + dist); + const maxFromCity = city.maxVisitorsTowardZoos ?? 999; + const contrib = Math.min(maxFromCity, raw * rawMultiplier); + sum += contrib; + } + return sum * scale; +} + +/** + * Decay multiplier when the zoo has not evolved (upgrade/place/sell) for a while. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {number} + */ +function getStagnationMultiplier(state, nowUnix) { + const after = GameConfig.Visitor.StagnationDecayAfterSeconds ?? 60; + const perMin = GameConfig.Visitor.StagnationDecayPerMinute ?? 0.05; + const last = state.lastEvolutionAt ?? 0; + const elapsed = Math.max(0, nowUnix - last); + if (elapsed <= after) return 1; + const minutesStagnant = (elapsed - after) / 60; + const decay = Math.min(0.9, minutesStagnant * perMin); + return Math.max(0.1, 1 - decay); +} + +/** + * Stay duration multiplier from boutiques and animal diversity (visitors stay longer). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getStayMultiplier(state) { + let shopBonus = 0; + for (const cell of Object.values(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") { + shopBonus += (cell.level ?? 1) * (GameConfig.Visitor.StayMultiplierPerShopLevel ?? 0.15); + } + } + const speciesSet = new Set(); + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) speciesSet.add(cell.id); + } + const diversityBonus = speciesSet.size * (GameConfig.Visitor.StayMultiplierPerSpecies ?? 0.02); + return Math.max(0.5, 1 + shopBonus + diversityBonus); +} + +/** + * Stay duration in seconds (base 1 day × stay multiplier). Visitors leave when now > arrivedAt + this. + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getStayDurationSeconds(state) { + const base = GameConfig.Time?.DayLengthSeconds ?? 120; + return base * getStayMultiplier(state); +} + +/** + * Demand for visitors (before billeterie cap). + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {number} + */ +function getVisitorDemand(state, nowUnix) { + let animalCount = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1; + } + const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal; + const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus; + let demand = Math.floor(animalCount * visitorsPerAnimal + plotBonus); + const cityAttraction = getCityAttraction(state); + const animalValue = getTotalAnimalValue(state); + const animalValueScale = GameConfig.Visitor.AnimalValueScale ?? 0.00015; + demand *= 1 + cityAttraction; + demand *= 1 + animalValue * animalValueScale; + demand *= getStagnationMultiplier(state, nowUnix); + return Math.max(0, Math.floor(demand)); +} + +/** + * Update visitor entities: remove those who exceeded stay duration, add new arrivals up to min(cap, demand). + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function tickVisitorArrivals(state, nowUnix) { + state.visitorArrivals = state.visitorArrivals ?? []; + const stayDuration = getStayDurationSeconds(state); + state.visitorArrivals = state.visitorArrivals.filter( + (v) => nowUnix < v.arrivedAt + stayDuration + ); + const demand = getVisitorDemand(state, nowUnix); + const cap = getBilleterieCapacity(state); + const target = Math.min(cap, demand); + const current = state.visitorArrivals.length; + for (let i = 0; i < target - current; i++) { + state.visitorArrivals.push({ arrivedAt: nowUnix }); + } +} + +/** + * Visitor count and average payment per visitor per second. Includes luxury guest effect (LuxuryGuestChance, LuxuryEntryMultiplier, LuxuryShopMultiplier) in the average. + * @param {import("./types.js").GameState} state + * @returns {{ visitorCount: number, paymentPerVisitor: number }} + */ +function getVisitorParams(state) { + const arrivals = state.visitorArrivals ?? []; + let visitorCount = arrivals.length; + if (visitorCount === 0 && getBilleterieCapacity(state) === 0) { + let animalCount = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1; + } + const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal; + const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus; + visitorCount = Math.max(0, Math.floor(animalCount * visitorsPerAnimal + plotBonus)); + } + const billeterieCap = getBilleterieCapacity(state); + if (billeterieCap > 0 && visitorCount > billeterieCap) visitorCount = billeterieCap; + let paymentPerVisitor = GameConfig.Visitor.BasePaymentPerVisitor; + let souvenirBonus = 1; + let shopCount = 0; + for (const cell of Object.values(state.grid.cells)) { + if (cell && cell.kind === "souvenirShop") shopCount += (cell.level ?? 1); + } + if (shopCount > 0) { + const bonusPerShop = GameConfig.Visitor.SouvenirShopBonusPerShop ?? 0.2; + souvenirBonus = 1 + shopCount * bonusPerShop; + const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0; + const luxuryShopMult = GameConfig.Visitor.LuxuryShopMultiplier ?? 1; + if (luxuryChance > 0 && luxuryShopMult > 1) { + souvenirBonus *= 1 + luxuryChance * (luxuryShopMult - 1); + } + } + paymentPerVisitor *= souvenirBonus; + const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0; + const luxuryEntryMult = GameConfig.Visitor.LuxuryEntryMultiplier ?? 1; + if (luxuryChance > 0 && luxuryEntryMult > 1) { + paymentPerVisitor *= 1 + luxuryChance * (luxuryEntryMult - 1); + } + return { visitorCount, paymentPerVisitor }; +} + +export function getVisitorCount(state) { + return getVisitorParams(state).visitorCount; +} + +/** + * Attractivity score for display and future city allocation. Formula: value + species + rarity + fill rate, minus death penalty, plus birth bonus. + * @param {import("./types.js").GameState} state + * @returns {number} + */ +export function getAttractivityScore(state) { + const value = getTotalAnimalValue(state); + const originCount = getOriginAnimalCount(state); + const grid = state.grid; + const cellCount = grid.width * grid.height; + const fillRate = cellCount > 0 ? originCount / cellCount : 0; + const speciesSet = new Set(); + let raritySum = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) { + // skip + } else { + speciesSet.add(cell.id); + const def = LootTables.Animals[cell.id]; + if (def) raritySum += def.rarityLevel ?? 1; + } + } + const speciesCount = speciesSet.size; + const avgRarity = originCount > 0 ? raritySum / originCount : 0; + const valueNorm = value * 0.001; + const speciesNorm = speciesCount * 2; + const rarityNorm = avgRarity * 0.5; + const fillNorm = fillRate * 10; + let score = valueNorm + speciesNorm + rarityNorm + fillNorm; + const deathPenalty = GameConfig.Visitor?.AttractivityDeathPenalty ?? 0.5; + const birthBonus = GameConfig.Visitor?.AttractivityBirthBonus ?? 0.2; + const deaths = state.deathCountRecent ?? 0; + const births = state.birthCount ?? 0; + score -= deathPenalty * deaths; + score += birthBonus * births; + const incidentBonus = state.attractivityBonusFromIncidents ?? 0; + score += incidentBonus; + return Math.max(0, score); +} + +/** + * @param {import("./types.js").AnimalCell} cell + * @returns {number} + */ +function incomePerSecond(cell) { + const animalDef = LootTables.Animals[cell.id]; + if (animalDef === null || animalDef === undefined) throw new Error("IncomeService: unknown animal"); + const mutationMult = getIncomeMultiplier(cell.mutation); + const levelMult = getLevelMultiplier(cell.level); + return animalDef.baseIncomePerSecond * mutationMult * levelMult; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} dt + * @param {{ incomeMultiplier: number }} eventModifiers + * @returns {{ animal: number, visitor: number }} + */ +export function tick(state, dt, eventModifiers) { + const prestigeMult = getPrestigeIncomeMultiplier(state.prestigeLevel); + let animalTotal = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell.kind === "animal" && isOriginCell(key, cell)) + animalTotal += incomePerSecond(cell) * dt * eventModifiers.incomeMultiplier * prestigeMult; + } + const { visitorCount, paymentPerVisitor } = getVisitorParams(state); + const visitorTotal = visitorCount * paymentPerVisitor * dt * prestigeMult; + const total = animalTotal + visitorTotal; + state.coins += total; + if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + total; + return { animal: animalTotal, visitor: visitorTotal }; +} diff --git a/web/js/loot-tables.js b/web/js/loot-tables.js new file mode 100644 index 0000000..22fa109 --- /dev/null +++ b/web/js/loot-tables.js @@ -0,0 +1,117 @@ +/** + * 15 color nuances, 5 rarity levels per color = 75 animals. + * Rarity levels scaled by Fibonacci: F(1)=1, F(2)=1, F(3)=2, F(4)=3, F(5)=5. + */ + +const NUM_COLORS = 15; +const NUM_RARITY_LEVELS = 5; + +const FIBONACCI = [0, 1, 1, 2, 3, 5]; + +export function getFibonacciRarity(rarityLevel) { + const n = Math.max(1, Math.min(NUM_RARITY_LEVELS, Math.floor(rarityLevel))); + return FIBONACCI[n] ?? 1; +} + +const BIOMES = ["Meadow", "Ocean", "Mountain"]; + +function getBiomeForColorIndex(colorIndex) { + return BIOMES[Math.floor((colorIndex % NUM_COLORS) / 5)] ?? "Meadow"; +} + +function buildAnimalId(colorIndex, rarityIndex) { + return `c${colorIndex}_r${rarityIndex}`; +} + +const EggTypes = {}; +const Animals = {}; + +for (let c = 0; c < NUM_COLORS; c++) { + const eggTypeKey = `Color_${c + 1}`; + const loot = []; + for (let r = 0; r < NUM_RARITY_LEVELS; r++) { + const animalId = buildAnimalId(c, r); + const rarityLevel = r + 1; + const fib = getFibonacciRarity(rarityLevel); + const biome = getBiomeForColorIndex(c); + const reproductionScoreByBiome = {}; + const survivalScoreByBiome = {}; + for (const b of BIOMES) { + reproductionScoreByBiome[b] = b === biome ? 1 : 0.5; + survivalScoreByBiome[b] = b === biome ? 1 : 0.6; + } + Animals[animalId] = { + baseIncomePerSecond: 0.4 * fib, + rarity: String(rarityLevel), + biome, + sellFactor: 12 + fib * 6, + rarityLevel, + cellsWide: (c === 7 && r === 1) ? 2 : 1, + cellsHigh: (c === 7 && r === 1) ? 2 : 1, + idealTemperature: 18 + (c % 5), + temperatureTolerance: 5, + reproductionScoreByBiome, + survivalScoreByBiome, + }; + const weight = 60 - r * 12; + loot.push({ id: animalId, weight: Math.max(10, weight) }); + } + EggTypes[eggTypeKey] = { + price: 35 + c * 12 + (c % 5) * 5, + hatchSeconds: 18 + c * 2, + minConveyorLevel: 1 + Math.floor(c / 5), + loot, + }; +} + +export const LootTables = { + EggTypes, + Animals, +}; + +export function getAnimalToEggTypeMap() { + const map = {}; + for (let c = 0; c < NUM_COLORS; c++) { + const eggTypeKey = `Color_${c + 1}`; + for (let r = 0; r < NUM_RARITY_LEVELS; r++) { + map[buildAnimalId(c, r)] = eggTypeKey; + } + } + return map; +} + +export function getColorNames() { + const names = []; + for (let i = 0; i < NUM_COLORS; i++) { + names.push(`Color_${i + 1}`); + } + return names; +} + +/** All color keys with value 0. Use for initial weights or aggregation. + * @returns {Record} + */ +export function zeroAnimalWeights() { + return Object.fromEntries(getColorNames().map((c) => [c, 0])); +} + +export function getRarityLevelFromAnimalId(animalId) { + const def = Animals[animalId]; + return def?.rarityLevel ?? 1; +} + +/** + * Hatch time multiplier from egg type rarity (rarer eggs take longer). Used when placing egg from nursery. + * @param {string} eggType + * @returns {number} + */ +export function getRarityHatchMultiplierForEggType(eggType) { + const def = EggTypes[eggType]; + if (!def || !def.loot || def.loot.length === 0) return 1; + let maxRarity = 1; + for (const entry of def.loot) { + const r = getRarityLevelFromAnimalId(entry.id); + if (r > maxRarity) maxRarity = r; + } + return 1 + (maxRarity - 1) * 0.2; +} diff --git a/web/js/main-bootstrap.js b/web/js/main-bootstrap.js new file mode 100644 index 0000000..9146196 --- /dev/null +++ b/web/js/main-bootstrap.js @@ -0,0 +1,178 @@ +/** + * Bootstrap helpers: load state from API or show register/connect UI. + */ + +import { defaultState, normalizeZooWeights } from "./state.js"; +import { refreshOffers, getPlayerZooWeights } from "./conveyor.js"; +import { ensureBotState } from "./bot-zoo.js"; +import { loadZoos, loadMyZoo, register, createMyZoo } from "./api-client.js"; +import { getOrCreateKeyPair } from "./auth-client.js"; + +/** + * @param {import("./types.js").GameState} gameState + * @param {{ zoosData: { worldZoos?: Array }, playerZooId?: string, playerName?: string, playerX?: number, playerY?: number }} opts + * @returns {void} + */ +export function applyWorldZoos(gameState, opts) { + const { zoosData, playerZooId, playerName, playerX, playerY } = opts; + const playerWeights = getPlayerZooWeights(gameState); + const playerEntry = { + id: "player", + name: playerName || "Mon zoo", + x: playerX ?? 25, + y: playerY ?? 50, + animalWeights: playerWeights, + }; + const others = (zoosData.worldZoos || []) + .filter((z) => z.id !== playerZooId) + .map((z) => ({ + ...z, + animalWeights: normalizeZooWeights(z.animalWeights), + botState: z.game_state ?? undefined, + })); + others.forEach((zoo) => ensureBotState(zoo, false)); + gameState.worldZoos = [playerEntry, ...others]; +} + +/** + * @returns {Promise} + */ +export function bootstrapNoKeys() { + const s = defaultState(); + const nowUnix = Math.floor(Date.now() / 1000); + refreshOffers(s, nowUnix); + return Promise.resolve(s); +} + +/** + * @param {{ worldZoos?: Array }} zoosData + * @param {HTMLElement} rootEl + * @param {(zooId: string) => void} setMyZooId + * @returns {Promise} + */ +export function bootstrapShowRegisterPanel(zoosData, rootEl, setMyZooId) { + return new Promise((resolve) => { + rootEl.innerHTML = "

Construis un zoo

Créer un compte (pseudo, pas de mot de passe)

"; + const errEl = document.getElementById("boot-err"); + document.getElementById("boot-submit").addEventListener("click", async () => { + const pseudo = document.getElementById("boot-pseudo").value.trim(); + if (pseudo.length < 2) { + errEl.textContent = "Pseudo min. 2 caractères"; + return; + } + errEl.textContent = ""; + try { + await register(pseudo); + const s = defaultState(); + const nowUnix = Math.floor(Date.now() / 1000); + refreshOffers(s, nowUnix); + const created = await createMyZoo(pseudo, s); + setMyZooId(created.zooId); + applyWorldZoos(s, { zoosData, playerZooId: created.zooId, playerName: created.name, playerX: created.x, playerY: created.y }); + s.myZooId = created.zooId; + s.playerName = created.name; + s.playerX = created.x; + s.playerY = created.y; + resolve(s); + } catch (e) { + errEl.textContent = e.message || "Erreur"; + } + }); + }); +} + +/** + * @param {{ worldZoos?: Array }} zoosData + * @returns {Promise} + */ +export async function bootstrapMe404(zoosData) { + const s = defaultState(); + const nowUnix = Math.floor(Date.now() / 1000); + refreshOffers(s, nowUnix); + const created = await createMyZoo("Mon zoo", s); + const myZooId = created.zooId; + applyWorldZoos(s, { zoosData, playerZooId: myZooId, playerName: created.name, playerX: created.x, playerY: created.y }); + s.myZooId = myZooId; + s.playerName = created.name; + s.playerX = created.x; + s.playerY = created.y; + return s; +} + +/** + * @param {{ game_state: import("./types.js").GameState; zooId: string; name: string; x: number; y: number }} me + * @param {{ worldZoos?: Array }} zoosData + * @returns {import("./types.js").GameState} + */ +export function bootstrapMeWithGameState(me, zoosData) { + const s = me.game_state; + const myZooId = me.zooId; + s.myZooId = myZooId; + s.playerName = me.name; + s.playerX = me.x; + s.playerY = me.y; + if (s.coins < 100) s.coins = 200; + const nowUnix = Math.floor(Date.now() / 1000); + if (s.conveyorOffers === null || s.conveyorOffers === undefined || s.conveyorOffers.length === 0) { + refreshOffers(s, nowUnix); + } + applyWorldZoos(s, { zoosData, playerZooId: myZooId, playerName: me.name, playerX: me.x, playerY: me.y }); + return s; +} + +/** + * @param {{ name?: string }} me + * @param {{ worldZoos?: Array }} zoosData + * @returns {Promise} + */ +export async function bootstrapMeCreateZoo(me, zoosData) { + const s = defaultState(); + const nowUnix = Math.floor(Date.now() / 1000); + refreshOffers(s, nowUnix); + const created = await createMyZoo(me.name || "Mon zoo", s); + const myZooId = created.zooId; + applyWorldZoos(s, { zoosData, playerZooId: myZooId, playerName: created.name, playerX: created.x, playerY: created.y }); + s.myZooId = myZooId; + s.playerName = created.name; + s.playerX = created.x; + s.playerY = created.y; + return s; +} + +/** + * @param {(zooId: string) => void} setMyZooId + * @param {HTMLElement} rootEl + * @returns {Promise} + */ +export async function bootstrapFromApi(setMyZooId, rootEl) { + const keys = await getOrCreateKeyPair(); + if (!keys) { + return bootstrapNoKeys(); + } + let zoosData = { worldZoos: [], mapWidth: 100, mapHeight: 100 }; + try { + zoosData = await loadZoos(); + } catch (e) { + console.warn("loadZoos failed", e); + } + const meRes = await loadMyZoo().catch((e) => { + console.warn("loadMyZoo failed", e); + return { status: 401 }; + }); + if (meRes.status === 401) { + return bootstrapShowRegisterPanel(zoosData, rootEl, setMyZooId); + } + if (meRes.status === 404) { + const s = await bootstrapMe404(zoosData); + setMyZooId(s.myZooId ?? "player"); + return s; + } + const me = meRes.data; + if (me.game_state && typeof me.game_state === "object") { + setMyZooId(me.zooId); + return bootstrapMeWithGameState(me, zoosData); + } + const s = await bootstrapMeCreateZoo(me, zoosData); + setMyZooId(s.myZooId ?? "player"); + return s; +} diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..739651f --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,193 @@ +import { defaultState, saveState } from "./state.js"; +import { refreshOffers } from "./conveyor.js"; +import { render } from "./ui.js"; +import { startGameLoop } from "./game-loop.js"; +import { playSound, setMusicEnabled, setMusicGetState } from "./audio.js"; +import { resolveIncident, INCIDENT_EMOJI } from "./visitor-incidents.js"; +import { getAttractionCenter, getVisitorPosition } from "./visitor-attraction.js"; +import { incidentLabel, incidentBubbleAria } from "./texts-fr.js"; +import { getApiBase, loadZoos, saveMyZoo, setApiBaseUrl } from "./api-client.js"; +import { bootstrapFromApi, applyWorldZoos } from "./main-bootstrap.js"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Missing #root"); + +let state = null; +let myZooId = null; + +function setMyZooId(id) { + myZooId = id; +} + +(async () => { + let base = getApiBase(); + if (!base) { + root.innerHTML = "

Construis un zoo

" + + "

Connectez-vous à un serveur pour jouer (compte et sauvegarde en base).

" + + "
" + + "" + + "

"; + const urlInput = document.getElementById("boot-api-url"); + try { + const stored = localStorage.getItem("builazoo_api_url"); + if (stored) urlInput.value = stored; + } catch (_) { + // ignore localStorage + } + const errEl = document.getElementById("boot-err"); + await new Promise((resolve) => { + document.getElementById("boot-connect").addEventListener("click", () => { + const url = urlInput.value.trim(); + if (!url) { + errEl.textContent = "Indiquez l'URL du serveur."; + return; + } + setApiBaseUrl(url); + resolve(); + }); + }); + root.innerHTML = ""; + base = getApiBase(); + } + if (base) { + root.innerHTML = "

Chargement…

"; + while (true) { + try { + state = await bootstrapFromApi(setMyZooId, root); + break; + } catch (e) { + console.error("bootstrapFromApi failed", e); + root.innerHTML = "

Construis un zoo

Erreur de connexion au serveur.

"; + const errP = root.querySelector(".boot-err"); + if (errP && e && e.message) errP.textContent = e.message; + await new Promise((resolve) => { + document.getElementById("boot-retry").addEventListener("click", () => resolve()); + }); + } + } + root.innerHTML = ""; + } + if (state) { + + try { + if (localStorage.getItem("builazoo_music") === "1") setMusicEnabled(true); + } catch (_) { + // ignore localStorage + } + + let lastHatched = []; + let fullRender = () => {}; + function getState() { + return state; + } + function doRestart() { + state = defaultState(); + const nowUnix = Math.floor(Date.now() / 1000); + refreshOffers(state, nowUnix); + saveState(state); + fullRender(); + } + fullRender = render(root, { + state: getState(), + setState: () => fullRender(), + getLastHatched: () => lastHatched, + onRestart: doRestart, + updateState: (partial) => { + Object.assign(state, partial); + fullRender(); + }, + }); + + let lastApiSaveAt = 0; + const MIN_API_SAVE_INTERVAL_MS = 5000; + function saveStateFn(s) { + saveState(s); + if (getApiBase()) { + const now = Date.now(); + if (now - lastApiSaveAt >= MIN_API_SAVE_INTERVAL_MS) { + lastApiSaveAt = now; + saveMyZoo(s).catch((e) => console.warn("saveMyZoo failed", e)); + } + } + } + + setMusicGetState(getState); + startGameLoop(getState, (s, payload) => { + if (payload?.lastHatched?.length) { + lastHatched = payload.lastHatched; + playSound("hatch"); + } + fullRender(); + setTimeout(() => { lastHatched = []; fullRender(); }, 1800); + }, saveStateFn); + + const ZOOS_REFETCH_INTERVAL_MS = 30 * 1000; + setInterval(() => { + loadZoos().then((zoosData) => { + applyWorldZoos(state, { zoosData, playerZooId: state.myZooId ?? myZooId, playerName: state.playerName ?? "Mon zoo", playerX: state.playerX ?? 25, playerY: state.playerY ?? 50 }); + fullRender(); + }).catch(() => {}); + }, ZOOS_REFETCH_INTERVAL_MS); + + let visitorAnimTime = 0; + function syncVisitorBubble(el, visitor, index) { + const incidentType = visitor && (visitor.incidentType === null || visitor.incidentType === undefined) + ? null + : (visitor && visitor.incidentType); + let bubble = el.querySelector(".visitor-incident-bubble"); + if (incidentType) { + if (!bubble) { + bubble = document.createElement("span"); + bubble.className = "visitor-incident-bubble"; + bubble.setAttribute("role", "button"); + bubble.setAttribute("tabindex", "0"); + bubble.setAttribute("aria-label", incidentBubbleAria); + bubble.addEventListener("click", () => { + if (resolveIncident(getState(), index)) fullRender(); + }); + el.appendChild(bubble); + } + bubble.textContent = INCIDENT_EMOJI[incidentType] ?? "❓"; + bubble.title = incidentLabel[incidentType] ?? incidentType; + } else if (bubble) { + bubble.remove(); + } + } + function updateVisitors() { + const layer = root.querySelector(".visitors-layer"); + if (!layer) { + requestAnimationFrame(updateVisitors); + return; + } + const currentState = getState(); + const arrivals = currentState.visitorArrivals ?? []; + const n = arrivals.length; + const w = currentState.grid.width; + const h = currentState.grid.height; + while (layer.children.length < n) { + const el = document.createElement("div"); + el.className = "visitor-sprite"; + el.setAttribute("aria-hidden", "true"); + el.textContent = "👤"; + layer.appendChild(el); + } + while (layer.children.length > n) { + const last = layer.lastChild; + if (last) last.remove(); + } + const { centerX, centerY } = getAttractionCenter(currentState, w, h); + for (let i = 0; i < n; i++) { + const el = layer.children[i]; + const { px, py } = getVisitorPosition({ + i, n, t: visitorAnimTime, centerX, centerY, gridWidth: w, gridHeight: h, + }); + el.style.left = `${px}px`; + el.style.top = `${py}px`; + syncVisitorBubble(el, arrivals[i], i); + } + visitorAnimTime += 0.016; + requestAnimationFrame(updateVisitors); + } + requestAnimationFrame(updateVisitors); + } +})(); diff --git a/web/js/mutation-rules.js b/web/js/mutation-rules.js new file mode 100644 index 0000000..bd629a2 --- /dev/null +++ b/web/js/mutation-rules.js @@ -0,0 +1,27 @@ +const Definitions = { + none: { incomeMultiplier: 1.0, weight: 0 }, + golden: { incomeMultiplier: 1.5, weight: 50 }, + crystal: { incomeMultiplier: 1.8, weight: 30 }, + void: { incomeMultiplier: 2.2, weight: 20 }, +}; + +/** + * @returns {Array<{ id: string, weight: number }>} + */ +export function getMutationEntries() { + const entries = []; + for (const [id, def] of Object.entries(Definitions)) { + if (id !== "none") entries.push({ id, weight: def.weight }); + } + return entries; +} + +/** + * @param {string} mutationId + * @returns {number} + */ +export function getIncomeMultiplier(mutationId) { + const def = Definitions[mutationId]; + if (def === null || def === undefined) return 1.0; + return def.incomeMultiplier; +} diff --git a/web/js/placement.js b/web/js/placement.js new file mode 100644 index 0000000..4f071be --- /dev/null +++ b/web/js/placement.js @@ -0,0 +1,332 @@ +import { cellKey, withinBounds, getBlockKeys } from "./grid-utils.js"; +import { + getNurseryBuildCost, + getNurseryUpgradeCost, + getSouvenirShopBuildCost, + getSouvenirShopUpgradeCost, + getBuildingBuildCost, + getBuildingUpgradeCost, + getBuildingMaxLevel, + BUILDING_KINDS, +} from "./economy.js"; +import { GameConfig } from "./config.js"; + +/** + * All keys that belong to the same animal block as the cell at (x, y). If not an animal or single-cell, returns [cellKey(x,y)]. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {string[]} + */ +export function getBlockKeysFromCell(state, x, y) { + const key = cellKey(x, y); + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== "animal") return [key]; + let ox = x; + let oy = y; + let w = cell.cellsWide ?? 1; + let h = cell.cellsHigh ?? 1; + if (cell.originKey !== null && cell.originKey !== undefined) { + const m = cell.originKey.match(/^(\d+)_(\d+)$/); + if (m) { + ox = Number(m[1]); + oy = Number(m[2]); + const origin = state.grid.cells[cell.originKey]; + if (origin && origin.kind === "animal") { + w = origin.cellsWide ?? 1; + h = origin.cellsHigh ?? 1; + } + } + } + return getBlockKeys(ox, oy, w, h); +} + +/** + * Check if a rectangular block can be placed (all cells empty and in bounds). Optionally exclude keys that belong to excludeOriginKey block. + * @param {import("./types.js").GameState} state + * @param {{ originX: number, originY: number, w: number, h: number, excludeOriginKey?: string }} opts + * @returns {[boolean, string?]} + */ +export function canPlaceMultiCell(state, opts) { + const { originX, originY, w, h, excludeOriginKey } = opts; + const excludeSet = new Set(); + if (excludeOriginKey !== null && excludeOriginKey !== undefined) { + const orig = state.grid.cells[excludeOriginKey]; + if (orig && orig.kind === "animal") { + const [ox, oy] = excludeOriginKey.split("_").map(Number); + const ow = orig.cellsWide ?? 1; + const oh = orig.cellsHigh ?? 1; + getBlockKeys(ox, oy, ow, oh).forEach((k) => excludeSet.add(k)); + } + } + for (let dy = 0; dy < h; dy++) { + for (let dx = 0; dx < w; dx++) { + const nx = originX + dx; + const ny = originY + dy; + if (!withinBounds(state.grid.width, state.grid.height, nx, ny)) return [false, "OutOfBounds"]; + const k = cellKey(nx, ny); + if (!excludeSet.has(k)) { + const c = state.grid.cells[k]; + if (c !== null && c !== undefined) return [false, "Occupied"]; + } + } + } + return [true, undefined]; +} + +/** + * Fill a block with the same animal data (originKey set to origin, each cell gets full copy). + * @param {import("./types.js").GameState} state + * @param {number} originX + * @param {number} originY + * @param {import("./types.js").AnimalCell} animalData + */ +export function fillAnimalBlock(state, originX, originY, animalData) { + const w = animalData.cellsWide ?? 1; + const h = animalData.cellsHigh ?? 1; + const originKey = cellKey(originX, originY); + const data = { ...animalData, originKey }; + for (const k of getBlockKeys(originX, originY, w, h)) { + state.grid.cells[k] = { ...data }; + } +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, string?]} + */ +export function canPlace(state, x, y) { + if (!withinBounds(state.grid.width, state.grid.height, x, y)) return [false, "OutOfBounds"]; + const key = cellKey(x, y); + if (state.grid.cells[key] !== null && state.grid.cells[key] !== undefined) return [false, "Occupied"]; + return [true, undefined]; +} + +/** + * @param {import("./types.js").GameState} state + * @param {{ eggType: string, tokenId: number, x: number, y: number, hatchAt: number, seed: number }} opts + * @returns {[boolean, string?]} + */ +export function placeEgg(state, opts) { + const { eggType, tokenId, x, y, hatchAt, seed } = opts; + const [ok, reason] = canPlace(state, x, y); + if (!ok) return [false, reason]; + const key = cellKey(x, y); + state.grid.cells[key] = { + kind: "egg", + eggType, + tokenId, + hatchAt, + seed, + }; + return [true, undefined]; +} + +/** + * Déplace le contenu d'une case vers une case vide (œuf ou animal). For multi-cell animals, moves the whole block. + * @param {import("./types.js").GameState} state + * @param {{ fromX: number, fromY: number, toX: number, toY: number }} opts + * @returns {[boolean, string?]} + */ +export function moveCell(state, opts) { + const { fromX, fromY, toX, toY } = opts; + const fromKey = cellKey(fromX, fromY); + const toKey = cellKey(toX, toY); + if (fromKey === toKey) return [false, "SameCell"]; + const source = state.grid.cells[fromKey]; + if (source === null || source === undefined) return [false, "NoSource"]; + if (source.kind === "animal") { + const blockKeys = getBlockKeysFromCell(state, fromX, fromY); + let ox = fromX; + let oy = fromY; + let w = source.cellsWide ?? 1; + let h = source.cellsHigh ?? 1; + if (source.originKey !== null && source.originKey !== undefined) { + const m = source.originKey.match(/^(\d+)_(\d+)$/); + if (m) { + ox = Number(m[1]); + oy = Number(m[2]); + const origin = state.grid.cells[source.originKey]; + if (origin && origin.kind === "animal") { + w = origin.cellsWide ?? 1; + h = origin.cellsHigh ?? 1; + } + } + } + const originKey = cellKey(ox, oy); + const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h, excludeOriginKey: originKey }); + if (!ok) return [false, reason]; + const animalData = { ...source, originKey: toKey, cellsWide: w, cellsHigh: h }; + for (const k of blockKeys) delete state.grid.cells[k]; + fillAnimalBlock(state, toX, toY, animalData); + return [true, undefined]; + } + const [ok, reason] = canPlace(state, toX, toY); + if (!ok) return [false, reason]; + state.grid.cells[toKey] = source; + delete state.grid.cells[fromKey]; + return [true, undefined]; +} + +/** + * Set an empty cell to nursery. Cell must be empty. Costs coins. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, string?]} + */ +export function tryBuildNursery(state, x, y) { + const [ok, reason] = canPlace(state, x, y); + if (!ok) return [ok, reason]; + const cost = getNurseryBuildCost(); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + state.grid.cells[cellKey(x, y)] = { kind: "nursery", level: 1 }; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + return [true, undefined]; +} + +/** + * Set an empty cell to souvenir shop. Cell must be empty. Costs coins. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, string?]} + */ +export function tryBuildSouvenirShop(state, x, y) { + const [ok, reason] = canPlace(state, x, y); + if (!ok) return [ok, reason]; + const cost = getSouvenirShopBuildCost(); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + state.grid.cells[cellKey(x, y)] = { kind: "souvenirShop", level: 1 }; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + return [true, undefined]; +} + +/** + * Upgrade a nursery cell. Cell must be nursery and below max level. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, string?]} + */ +export function tryUpgradeNursery(state, x, y) { + const key = cellKey(x, y); + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== "nursery") return [false, "NotNursery"]; + const maxLevel = GameConfig.Nursery?.MaxLevel ?? 5; + const level = cell.level ?? 1; + if (level >= maxLevel) return [false, "NurseryMaxLevel"]; + const cost = getNurseryUpgradeCost(level); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + cell.level = level + 1; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + return [true, undefined]; +} + +/** + * Upgrade a souvenir shop cell. Cell must be souvenirShop and below max level. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, string?]} + */ +export function tryUpgradeSouvenirShop(state, x, y) { + const key = cellKey(x, y); + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== "souvenirShop") return [false, "NotSouvenirShop"]; + const maxLevel = GameConfig.SouvenirShop?.MaxLevel ?? 5; + const level = cell.level ?? 1; + if (level >= maxLevel) return [false, "SouvenirShopMaxLevel"]; + const cost = getSouvenirShopUpgradeCost(level); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + cell.level = level + 1; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + return [true, undefined]; +} + +/** + * Build a building of the given kind on an empty cell. Used by research, billeterie, food, reception, biomeChangeColor, biomeChangeTemp. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @param {typeof BUILDING_KINDS[number]} kind + * @returns {[boolean, string?]} + */ +export function tryBuildBuilding(state, x, y, kind) { + if (!BUILDING_KINDS.includes(kind)) return [false, "UnknownBuilding"]; + const [ok, reason] = canPlace(state, x, y); + if (!ok) return [ok, reason]; + const cost = getBuildingBuildCost(kind); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + state.grid.cells[cellKey(x, y)] = { kind, level: 1 }; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + return [true, undefined]; +} + +/** + * Upgrade a building cell of the given kind. Cell must be that kind and below max level. + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @param {typeof BUILDING_KINDS[number]} kind + * @returns {[boolean, string?]} + */ +export function tryUpgradeBuilding(state, x, y, kind) { + if (!BUILDING_KINDS.includes(kind)) return [false, "UnknownBuilding"]; + const key = cellKey(x, y); + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== kind) return [false, `Not${kind.charAt(0).toUpperCase() + kind.slice(1)}`]; + const maxLevel = getBuildingMaxLevel(kind); + const level = cell.level ?? 1; + if (level >= maxLevel) return [false, `${kind.charAt(0).toUpperCase() + kind.slice(1)}MaxLevel`]; + const cost = getBuildingUpgradeCost(kind, level); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + cell.level = level + 1; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + return [true, undefined]; +} + +export function tryBuildResearch(state, x, y) { + return tryBuildBuilding(state, x, y, "research"); +} +export function tryUpgradeResearch(state, x, y) { + return tryUpgradeBuilding(state, x, y, "research"); +} +export function tryBuildBilleterie(state, x, y) { + return tryBuildBuilding(state, x, y, "billeterie"); +} +export function tryUpgradeBilleterie(state, x, y) { + return tryUpgradeBuilding(state, x, y, "billeterie"); +} +export function tryBuildFood(state, x, y) { + return tryBuildBuilding(state, x, y, "food"); +} +export function tryUpgradeFood(state, x, y) { + return tryUpgradeBuilding(state, x, y, "food"); +} +export function tryBuildReception(state, x, y) { + return tryBuildBuilding(state, x, y, "reception"); +} +export function tryUpgradeReception(state, x, y) { + return tryUpgradeBuilding(state, x, y, "reception"); +} +export function tryBuildBiomeChangeColor(state, x, y) { + return tryBuildBuilding(state, x, y, "biomeChangeColor"); +} +export function tryUpgradeBiomeChangeColor(state, x, y) { + return tryUpgradeBuilding(state, x, y, "biomeChangeColor"); +} +export function tryBuildBiomeChangeTemp(state, x, y) { + return tryBuildBuilding(state, x, y, "biomeChangeTemp"); +} +export function tryUpgradeBiomeChangeTemp(state, x, y) { + return tryUpgradeBuilding(state, x, y, "biomeChangeTemp"); +} diff --git a/web/js/prestige.js b/web/js/prestige.js new file mode 100644 index 0000000..38e6050 --- /dev/null +++ b/web/js/prestige.js @@ -0,0 +1,56 @@ +import { GameConfig } from "./config.js"; +import { plotSizeFromLevel } from "./grid-utils.js"; +import { buildDefaultRow1Cells, addStarterAnimals } from "./default-grid-layout.js"; + +/** + * @param {import("./types.js").GameState} state + * @returns {boolean} + */ +export function canPrestige(state) { + return (state.coins ?? 0) >= GameConfig.Prestige.MinCoinsToReset; +} + +/** + * @param {import("./types.js").GameState} state + * @returns {import("./types.js").GameState} new state after reset + */ +export function doPrestige(state) { + if (!canPrestige(state)) return state; + const newLevel = (state.prestigeLevel ?? 0) + 1; + const [width, height] = plotSizeFromLevel(1); + state.coins = 0; + state.conveyorLevel = 1; + state.plotLevel = 1; + state.truckLevel = 1; + const cells = buildDefaultRow1Cells(); + state.grid = { width, height, cells }; + addStarterAnimals(state); + state.worldMapLevel = 1; + state.pendingEggTokens = []; + state.pendingBabies = []; + state.receptionAnimals = []; + state.nextTokenId = 1; + state.conveyorOffers = []; + state.lastOfferRefreshAt = 0; + state.worldTruckSales = []; + delete state.truckSale; + state.laboratoryOffer = null; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + state.prestigeLevel = newLevel; + state.timeOfDay = 6; + state.weather = "sun"; + state.lastWeatherChangeAt = 0; + state.quests = []; + state.lastQuestDay = ""; + state.stats = { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 }; + return state; +} + +/** + * @param {number} prestigeLevel + * @returns {number} + */ +export function getPrestigeIncomeMultiplier(prestigeLevel) { + const level = prestigeLevel ?? 0; + return 1 + level * GameConfig.Prestige.IncomeBonusPerLevel; +} diff --git a/web/js/quests.js b/web/js/quests.js new file mode 100644 index 0000000..2b77eb8 --- /dev/null +++ b/web/js/quests.js @@ -0,0 +1,98 @@ +import { GameConfig } from "./config.js"; + +const QUEST_TEMPLATES = [ + { descriptionKey: "questPlaceEggs", targetKey: "eggsPlaced", targetBase: 3 }, + { descriptionKey: "questEarnCoins", targetKey: "coinsEarned", targetBase: 100 }, + { descriptionKey: "questSellAnimals", targetKey: "animalsSold", targetBase: 2 }, + { descriptionKey: "questUpgradeConveyor", targetKey: "conveyorUpgrades", targetBase: 1 }, + { descriptionKey: "questUpgradePlot", targetKey: "plotUpgrades", targetBase: 1 }, +]; + +/** + * @param {string} dateKey YYYY-MM-DD + * @returns {number} + */ +function daySeed(dateKey) { + let h = 0; + for (let i = 0; i < dateKey.length; i++) h = (h * 31 + dateKey.charCodeAt(i)) >>> 0; + return h; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} level + * @returns {number} + */ +function _questReward(state, level) { + return GameConfig.Quests.RewardBase + level * GameConfig.Quests.RewardPerLevel; +} + +/** + * @param {import("./types.js").GameState} state + * @returns {import("./types.js").Quest[]} + */ +export function generateDailyQuests(state) { + const today = new Date().toISOString().slice(0, 10); + if (state.lastQuestDay === today && state.quests?.length > 0) return state.quests; + state.lastQuestDay = today; + let seed = daySeed(today); + const rng = () => { + seed = (seed * 1103515245 + 12345) >>> 0; + return (seed >>> 16) / 65536; + }; + const shuffled = [...QUEST_TEMPLATES].sort(() => rng() - 0.5); + const count = Math.min(GameConfig.Quests.CountPerDay, shuffled.length); + const level = (state.prestigeLevel ?? 0) + state.plotLevel + state.conveyorLevel; + state.quests = shuffled.slice(0, count).map((q, i) => ({ + id: `q-${today}-${i}`, + descriptionKey: q.descriptionKey, + target: q.targetBase, + current: 0, + reward: GameConfig.Quests.RewardBase + level * GameConfig.Quests.RewardPerLevel, + done: false, + })); + return state.quests; +} + +/** @param {import("./types.js").GameState} state */ +function getQuestProgress(state) { + const s = state.stats ?? { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, coinsEarned: 0 }; + return { + eggsPlaced: s.eggsPlaced ?? 0, + animalsSold: s.animalsSold ?? 0, + conveyorUpgrades: s.conveyorUpgrades ?? 0, + plotUpgrades: s.plotUpgrades ?? 0, + coinsEarned: s.coinsEarned ?? 0, + }; +} + +/** + * @param {import("./types.js").GameState} state + * @returns {number} coins awarded from completed quests this tick + */ +export function tickQuests(state) { + generateDailyQuests(state); + const progress = getQuestProgress(state); + let earned = 0; + for (const q of state.quests ?? []) { + if (q.done) { + // already done + } else { + let current = null; + if (q.descriptionKey === "questPlaceEggs") current = progress.eggsPlaced; + else if (q.descriptionKey === "questSellAnimals") current = progress.animalsSold; + else if (q.descriptionKey === "questUpgradeConveyor") current = progress.conveyorUpgrades; + else if (q.descriptionKey === "questUpgradePlot") current = progress.plotUpgrades; + else if (q.descriptionKey === "questEarnCoins") current = progress.coinsEarned ?? 0; + if (current !== null && current !== undefined) { + q.current = Math.min(current, q.target); + if (q.current >= q.target) { + q.done = true; + earned += q.reward; + } + } + } + } + state.coins += earned; + return earned; +} diff --git a/web/js/reproduction.js b/web/js/reproduction.js new file mode 100644 index 0000000..d757c62 --- /dev/null +++ b/web/js/reproduction.js @@ -0,0 +1,231 @@ +/** + * Reproduction: pairs of same-type animals (at least one from another zoo) in proximity + * produce a baby after a delay. Delay is reduced by zoo reproduction score and biome/temperature fit. + */ + +import { GameConfig } from "./config.js"; +import { LootTables } from "./loot-tables.js"; +import { cellKey, isOriginCell } from "./grid-utils.js"; +import { getBlockKeysFromCell } from "./placement.js"; +import { getDisplayBiome, getDisplayTemperature } from "./biome-rules.js"; +import { addPendingBaby } from "./zoo.js"; + +/** + * Zoo reproduction score (stub for phase 7). Higher = shorter delay until baby. + * @param {import("./types.js").GameState} state + * @returns {number} + */ +export function getReproductionScore(state) { + const birthCount = state.birthCount ?? 0; + const feedingRate = state.feedingRate ?? 1; + return Math.max(0.5, 1 + birthCount * 0.05 + feedingRate * 0.3); +} + +/** + * Reproduction factor from animal's fit to cell biome (from loot-tables). + * @param {import("./loot-tables.js").LootTables["Animals"][string]} def + * @param {string} cellBiome + * @returns {number} + */ +export function getBiomeReproductionFactor(def, cellBiome) { + if (!def || !def.reproductionScoreByBiome) return 0.5; + return def.reproductionScoreByBiome[cellBiome] ?? 0.5; +} + +/** + * Temperature factor: 1 when within ideal ± tolerance, else reduced. + * @param {import("./loot-tables.js").LootTables["Animals"][string]} def + * @param {number} displayTemp + * @returns {number} + */ +export function getTemperatureFactor(def, displayTemp) { + const ideal = def?.idealTemperature ?? 18; + const tolerance = def?.temperatureTolerance ?? 5; + const dist = Math.abs(displayTemp - ideal); + if (dist <= tolerance) return 1; + return Math.max(0.3, 1 - 0.2 * (dist / tolerance)); +} + +/** + * Neighbor keys (edge-adjacent) for a cell key "x_y". Does not check bounds. + * @param {string} key + * @returns {string[]} + */ +function getNeighborKeys(key) { + const m = key.match(/^(\d+)_(\d+)$/); + if (!m) return []; + const x = Number(m[1]); + const y = Number(m[2]); + return [cellKey(x - 1, y), cellKey(x + 1, y), cellKey(x, y - 1), cellKey(x, y + 1)]; +} + +/** + * True if the two blocks (by origin key) are adjacent (any cell of one touches any cell of the other). + * @param {import("./types.js").GameState} state + * @param {string} keyA origin key "ox_oy" + * @param {string} keyB origin key "ox_oy" + * @returns {boolean} + */ +function blocksAreAdjacent(state, keyA, keyB) { + const m1 = keyA.match(/^(\d+)_(\d+)$/); + const m2 = keyB.match(/^(\d+)_(\d+)$/); + if (!m1 || !m2) return false; + const setA = new Set(getBlockKeysFromCell(state, Number(m1[1]), Number(m1[2]))); + const setB = new Set(getBlockKeysFromCell(state, Number(m2[1]), Number(m2[2]))); + for (const k of setA) { + for (const neighbor of getNeighborKeys(k)) { + if (setB.has(neighbor)) return true; + } + } + return false; +} + +/** + * All eligible reproduction pairs: same animalId, at least one fromOtherZoo, adjacent. + * Returns unique pairs with keyA < keyB lexicographically. + * @param {import("./types.js").GameState} state + * @returns {Array<{ keyA: string, keyB: string, animalId: string }>} + */ +export function findReproductionPairs(state) { + const cells = state.grid.cells; + const origins = []; + for (const [key, cell] of Object.entries(cells)) { + if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) { + const def = LootTables.Animals[cell.id]; + if (def !== null && def !== undefined) { + origins.push({ + key, + animalId: cell.id, + fromOtherZoo: cell.fromOtherZoo === true, + }); + } + } + } + const pairs = []; + for (let i = 0; i < origins.length; i++) { + for (let j = i + 1; j < origins.length; j++) { + const a = origins[i]; + const b = origins[j]; + if (a.animalId === b.animalId && (a.fromOtherZoo || b.fromOtherZoo) && blocksAreAdjacent(state, a.key, b.key)) { + const keyA = a.key < b.key ? a.key : b.key; + const keyB = a.key < b.key ? b.key : a.key; + pairs.push({ keyA, keyB, animalId: a.animalId }); + } + } + } + return pairs; +} + +/** + * Unique pair key for deduplication. + * @param {string} keyA + * @param {string} keyB + * @returns {string} + */ +function pairKey(keyA, keyB) { + return keyA < keyB ? `${keyA},${keyB}` : `${keyB},${keyA}`; +} + +/** + * Process due reproduction timers: add baby or sale listing, remove timer. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>} timers + * @param {number} index + */ +function processDueTimer(state, nowUnix, timers, index) { + const t = timers[index]; + if (t.dueAt > nowUnix) return; + const [ok, result] = addPendingBaby(state, t.animalId, false); + if (ok) { + state.birthCount = (state.birthCount ?? 0) + 1; + } else if (result === "NoFreeNursery") { + state.saleListings = state.saleListings ?? []; + const listingId = `sale_${state.nextTokenId}`; + state.nextTokenId += 1; + state.saleListings.push({ + id: listingId, + zooId: state.myZooId ?? "player", + animalId: t.animalId, + isBaby: true, + price: 50, + endAt: nowUnix + 3600, + reproductionScoreAtSale: getReproductionScore(state), + }); + state.birthCount = (state.birthCount ?? 0) + 1; + } + timers.splice(index, 1); +} + +/** + * Remove timers whose cells are no longer valid animals. + * @param {import("./types.js").GameState} state + * @param {Array<{ keyA: string, keyB: string }>} timers + */ +function pruneInvalidTimers(state, timers) { + const cells = state.grid.cells; + for (let i = timers.length - 1; i >= 0; i--) { + const t = timers[i]; + const cellA = cells[t.keyA]; + const cellB = cells[t.keyB]; + if (!cellA || cellA.kind !== "animal" || !cellB || cellB.kind !== "animal") { + timers.splice(i, 1); + } + } +} + +/** + * Add new reproduction pairs to timers with dueAt. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>} timers + * @param {Set} existingSet + */ +function addNewPairsToTimers(state, nowUnix, timers, existingSet) { + const baseSeconds = GameConfig.Reproduction?.BaseSeconds ?? 60; + const currentPairs = findReproductionPairs(state); + const score = getReproductionScore(state); + const grid = state.grid; + for (const { keyA, keyB, animalId } of currentPairs) { + const pk = pairKey(keyA, keyB); + if (existingSet.has(pk)) { + // skip already tracked pair + } else { + const def = LootTables.Animals[animalId]; + if (def !== null && def !== undefined) { + const m1 = keyA.match(/^(\d+)_(\d+)$/); + const m2 = keyB.match(/^(\d+)_(\d+)$/); + if (m1 && m2) { + const biome1 = getDisplayBiome(Number(m1[1]), Number(m1[2]), grid); + const biome2 = getDisplayBiome(Number(m2[1]), Number(m2[2]), grid); + const temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid); + const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid); + const biomeFactor = (getBiomeReproductionFactor(def, biome1) + getBiomeReproductionFactor(def, biome2)) / 2; + const tempFactor = (getTemperatureFactor(def, temp1) + getTemperatureFactor(def, temp2)) / 2; + const factor = Math.max(0.2, score * biomeFactor * tempFactor); + const delay = Math.max(5, baseSeconds / factor); + timers.push({ keyA, keyB, animalId, dueAt: nowUnix + Math.floor(delay) }); + existingSet.add(pk); + } + } + } + } +} + +/** + * Run reproduction tick: spawn babies for due timers, then register new pairs with dueAt. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function tickReproduction(state, nowUnix) { + const timers = state.reproductionTimers ?? []; + + for (let i = timers.length - 1; i >= 0; i--) { + processDueTimer(state, nowUnix, timers, i); + } + + const existingSet = new Set(timers.map((t) => pairKey(t.keyA, t.keyB))); + pruneInvalidTimers(state, timers); + addNewPairsToTimers(state, nowUnix, timers, existingSet); + state.reproductionTimers = timers; +} diff --git a/web/js/state.js b/web/js/state.js new file mode 100644 index 0000000..8a3fa5e --- /dev/null +++ b/web/js/state.js @@ -0,0 +1,232 @@ +import { GameConfig } from "./config.js"; +import { plotSizeFromLevel } from "./grid-utils.js"; +import { LootTables, getColorNames, zeroAnimalWeights } from "./loot-tables.js"; +import { ensureBotState } from "./bot-zoo.js"; +import { buildDefaultRow1Cells, addStarterAnimals } from "./default-grid-layout.js"; + +export function defaultAnimalWeights() { + const w = zeroAnimalWeights(); + w[getColorNames()[0]] = 1; + return w; +} + +export function normalizeZooWeights(legacy) { + if (!legacy || typeof legacy !== "object") return defaultAnimalWeights(); + const keys = getColorNames(); + const w = zeroAnimalWeights(); + const map = { Basic: keys[0], Ocean: keys[5], Mountain: keys[10] }; + for (const [oldKey, val] of Object.entries(legacy)) { + const newKey = map[oldKey] ?? oldKey; + if (keys.includes(newKey)) w[newKey] = Number(val) || 0; + } + return w; +} + +/** + * @returns {import("./types.js").GameState} + */ +export function defaultState() { + const [width, height] = plotSizeFromLevel(1); + const worldZoos = buildDefaultWorldZoos(); + const cells = buildDefaultCells(); + const state = buildStatePayload(width, height, worldZoos, cells); + addStarterAnimals(state); + return state; +} + +function buildDefaultWorldZoos() { + const configZoos = GameConfig.WorldMap?.Zoos; + if (configZoos && configZoos.length > 0) { + const worldZoos = configZoos.map((z, i) => ({ + id: z.id, + name: z.name, + x: z.x, + y: z.y, + animalWeights: i === 0 ? defaultAnimalWeights() : normalizeZooWeights(z.animalWeights), + })); + worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player")); + return worldZoos; + } + return [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }]; +} + +function buildDefaultCells() { + return buildDefaultRow1Cells(); +} + +function buildStatePayload(width, height, worldZoos, cells) { + return { + version: GameConfig.StateVersion, + coins: 200, + conveyorLevel: 1, + plotLevel: 1, + truckLevel: 1, + grid: { width, height, cells }, + pendingEggTokens: [], + nextTokenId: 1, + conveyorOffers: [], + lastOfferRefreshAt: 0, + worldZoos, + truckSale: undefined, + worldTruckSales: [], + lastEvolutionAt: Math.floor(Date.now() / 1000), + laboratoryOffer: null, + prestigeLevel: 0, + timeOfDay: 6, + weather: "sun", + lastWeatherChangeAt: 0, + quests: [], + lastQuestDay: "", + stats: { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 }, + mapZoom: 1, + mapPanX: 0, + mapPanY: 0, + worldMapLevel: 1, + autoMode: false, + autoModeProfile: "balanced", + researchPoints: 0, + pendingBabies: [], + receptionAnimals: [], + saleListings: [], + deathCountRecent: 0, + birthCount: 0, + reproductionTimers: [], + visitorArrivals: [], + }; +} + +const STORAGE_KEY = "builazoo_state"; + +/** + * @param {import("./types.js").GameState} state + */ +export function saveState(state) { + try { + const toSave = { ...state }; + delete toSave.autoProfilePickerOpen; + delete toSave.autoProfilePickerFamily; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + } catch (e) { + console.error("saveState failed", e); + } +} + +/** + * @returns {import("./types.js").GameState | null} + */ +export function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null || raw === undefined) return null; + const data = JSON.parse(raw); + if (!data || typeof data.coins !== "number" || !data.grid || typeof data.grid.cells !== "object") return null; + applyLoadStateDefaults(data); + normalizeLoadedCells(data.grid.cells); + ensureSchoolCell(data); + return data; + } catch (e) { + console.error("loadState failed", e); + return null; + } +} + +function applyLoadStateDefaults(data) { + applyLoadStateWorldZoos(data); + applyLoadStateScalarDefaults(data); + applyLoadStateLegacyCells(data); +} + +function applyLoadStateWorldZoos(data) { + if (data.coins < 100) data.coins = 200; + if (data.pendingEggTokens === null || data.pendingEggTokens === undefined) data.pendingEggTokens = []; + if (data.conveyorOffers === null || data.conveyorOffers === undefined) data.conveyorOffers = []; + data.conveyorOffers = data.conveyorOffers.map((o) => ({ ...o, zooId: o.zooId ?? "player" })); + if ((data.worldZoos === null || data.worldZoos === undefined) && GameConfig.WorldMap && GameConfig.WorldMap.Zoos) { + data.worldZoos = [...GameConfig.WorldMap.Zoos]; + } + if (data.worldZoos !== null && data.worldZoos !== undefined && Array.isArray(data.worldZoos)) { + const keys = getColorNames(); + data.worldZoos = data.worldZoos.map((z, _i) => ({ + ...z, + animalWeights: z.animalWeights && keys.some((k) => k in (z.animalWeights ?? {})) + ? z.animalWeights + : normalizeZooWeights(z.animalWeights), + })); + data.worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player")); + } + if (data.worldZoos === null || data.worldZoos === undefined) data.worldZoos = [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }]; +} + +/** Set data[key] to defaultVal when data[key] is null or undefined. defaultVal may be a function (called for value). */ +function setScalarDefault(data, key, defaultVal) { + if (data[key] === null || data[key] === undefined) { + data[key] = typeof defaultVal === "function" ? defaultVal() : defaultVal; + } +} + +const LOAD_STATE_SCALAR_DEFAULTS = [ + ["worldTruckSales", []], + ["lastEvolutionAt", () => Math.floor(Date.now() / 1000)], + ["nextTokenId", 1], + ["prestigeLevel", 0], + ["timeOfDay", 6], + ["weather", "sun"], + ["lastWeatherChangeAt", 0], + ["quests", []], + ["lastQuestDay", ""], + ["stats", { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 }], + ["truckLevel", 1], + ["mapZoom", 1], + ["mapPanX", 0], + ["mapPanY", 0], + ["worldMapLevel", 1], + ["researchPoints", 0], + ["pendingBabies", []], + ["receptionAnimals", []], + ["saleListings", []], + ["deathCountRecent", 0], + ["birthCount", 0], + ["reproductionTimers", []], + ["visitorArrivals", []], +]; + +function applyLoadStateScalarDefaults(data) { + if (data.laboratoryOffer === undefined) data.laboratoryOffer = null; + for (const [key, defaultVal] of LOAD_STATE_SCALAR_DEFAULTS) { + setScalarDefault(data, key, defaultVal); + } + if (data.version !== GameConfig.StateVersion) data.version = GameConfig.StateVersion; + data.autoProfilePickerOpen = false; + data.autoProfilePickerFamily = undefined; + if (data.attractivityBonusFromIncidents === null || data.attractivityBonusFromIncidents === undefined) { + data.attractivityBonusFromIncidents = 0; + } +} + +function applyLoadStateLegacyCells(data) { + if (data.grid.cells["2_1"] === null || data.grid.cells["2_1"] === undefined) data.grid.cells["2_1"] = { kind: "nursery", level: 1 }; + const c21 = data.grid.cells["2_1"]; + if (c21 && (c21.kind === "plotUpgrade" || c21.kind === "worldMapUpgrade")) data.grid.cells["2_1"] = { kind: "nursery", level: 1 }; + const c12 = data.grid.cells["1_2"]; + if (c12 && (c12.kind === "plotUpgrade" || c12.kind === "worldMapUpgrade")) delete data.grid.cells["1_2"]; +} + +function normalizeLoadedCells(cells) { + const now = Math.floor(Date.now() / 1000); + for (const key of Object.keys(cells)) { + const cell = cells[key]; + if (cell) { + if (cell.kind === "animal" && cell.id && !LootTables.Animals[cell.id]) cell.id = "c0_r0"; + if (cell.kind === "animal" && (cell.lastVisitedAt === null || cell.lastVisitedAt === undefined)) cell.lastVisitedAt = now; + if (cell.kind === "animal" && (cell.lastFedAt === null || cell.lastFedAt === undefined)) cell.lastFedAt = cell.placedAt ?? now; + if (cell.kind === "egg" && cell.eggType && !LootTables.EggTypes[cell.eggType]) cell.eggType = "Color_1"; + } + } +} + +function ensureSchoolCell(data) { + const hasSchool = Object.values(data.grid.cells).some((c) => c && c.kind === "school"); + if (!hasSchool && !data.grid.cells["1_1"] && (data.conveyorLevel || 0) >= 1) { + data.grid.cells["1_1"] = { kind: "school", level: data.conveyorLevel || 1 }; + } +} diff --git a/web/js/texts-fr.js b/web/js/texts-fr.js new file mode 100644 index 0000000..e4ecb80 --- /dev/null +++ b/web/js/texts-fr.js @@ -0,0 +1,175 @@ +/** Textes et libellés en français */ + +import { getColorNames } from "./loot-tables.js"; + +const colorNames = getColorNames(); +export const eggTypeLabel = Object.fromEntries( + colorNames.map((key, i) => [key, `Couleur ${i + 1}`]) +); + +const animalLabelEntries = []; +for (let c = 0; c < 15; c++) { + for (let r = 0; r < 5; r++) { + const id = `c${c}_r${r}`; + animalLabelEntries.push([id, `C${c + 1} Niv.${r + 1}`]); + } +} +export const animalLabel = Object.fromEntries(animalLabelEntries); + +export const rarityLabel = { + "1": "Niveau 1", + "2": "Niveau 2", + "3": "Niveau 3", + "4": "Niveau 4", + "5": "Niveau 5", + Common: "Commun", + Uncommon: "Peu commun", + Rare: "Rare", + Epic: "Épique", + Legendary: "Légendaire", +}; + +export const t = { + title: "Construis un zoo", + plotTitle: "Parcelle", + zooTabTitle: "Carte du zoo", + worldMapTabTitle: "Carte du monde", + statusTemplate: "Pièces : %d · Parcelle %d · Compétences %d · Case : %d,%d · Œufs à placer : %d", + conveyorHint: "Cliquez sur un œuf sur le tapis pour l’acheter, puis sur une case pour le placer. Glissez-déposez un œuf ou un animal pour le déplacer.", + buyFailed: "Achat impossible : %s", + boughtToken: "Œuf %s acheté (à placer sur la grille).", + upgradeConveyor: "Améliorer le tapis", + upgradeConveyorCost: "%d pièces", + upgradePlot: "Agrandir la parcelle", + upgradePlotCost: "%d pièces", + sellAnimal: "Vendre (glisser l’animal sur le camion)", + upgradeConveyorFailed: "Amélioration tapis impossible : %s", + upgradePlotFailed: "Agrandissement impossible : %s", + upgradeWorldMapFailed: "Agrandissement carte impossible : %s", + sellFailed: "Vente impossible : %s", + errorPrefix: "Erreur : %s", + noOffer: "—", + helpConveyor: "Carte du monde : d’autres zoos (et le vôtre) proposent des œufs. Plus vos compétences sont développées, plus vous voyez de zoos avec des œufs chers. Cliquez sur un œuf pour l’acheter.", + helpGrid: "Cliquez sur une case pour la sélectionner. Si vous avez un œuf acheté, un clic place l’œuf ici. Glissez un œuf ou un animal vers une case vide pour le déplacer. Glissez un animal sur le camion pour le vendre à un autre zoo.", + helpUpgradeConveyor: "Développe les compétences : accès à plus de zoos et types d’œufs (Océan, Montagne).", + helpUpgradePlot: "Agrandit la grille pour placer plus d’animaux.", + helpUpgradeWorldMap: "Agrandit la vue de la carte du monde (zoom).", + helpSell: "Glissez un animal depuis la grille et déposez-le sur le camion pour le vendre à un autre zoo.", + helpStatus: "Pièces, niveau de parcelle, case sélectionnée, niveau de compétences, visiteurs, œufs à vendre sur la carte.", +}; + +/** Messages d'erreur pour les codes renvoyés par le jeu */ +export const errorMessage = { + OfferUnavailable: "Cette offre n’est plus disponible.", + NotEnoughCoins: "Pas assez de pièces.", + UnknownEgg: "Type d’œuf inconnu.", + InvalidToken: "Œuf invalide ou déjà placé.", + OutOfBounds: "Case hors de la grille.", + Occupied: "Case déjà occupée.", + ConveyorMaxLevel: "Compétences déjà au niveau max.", + TruckMaxLevel: "Camion déjà au niveau max.", + PlotMaxLevel: "Parcelle déjà au niveau max.", + WorldMapMaxLevel: "Carte du monde déjà au niveau max.", + NotEnoughResearch: "Pas assez d’unités de recherche.", + NoAnimal: "Aucun animal sur cette case.", + NoSchool: "Aucune école sur cette case.", + SameCell: "Source et destination identiques.", + NoSource: "Aucun objet sur la case de départ.", + NurseryMaxLevel: "Nurserie au niveau max.", + SouvenirShopMaxLevel: "Boutique au niveau max.", + NotNursery: "Ce n’est pas une nurserie.", + NotSouvenirShop: "Ce n’est pas une boutique.", + BabyNotMature: "Le bébé n’est pas encore prêt.", + NoBabyInNursery: "Aucun bébé dans cette nurserie.", + AnimalNotReady: "L’animal n’est pas encore prêt (accueil).", + NoAnimalInReception: "Aucun animal dans cet accueil.", +}; + +export const questDescription = { + questPlaceEggs: "Placer %d œuf(s)", + questEarnCoins: "Gagner %d pièces", + questSellAnimals: "Vendre %d animal(aux)", + questUpgradeConveyor: "Développer les compétences (%d fois)", + questUpgradePlot: "Agrandir la parcelle (%d fois)", +}; + +export const timePhaseLabel = { dawn: "Aube", day: "Jour", dusk: "Crépuscule", night: "Nuit" }; +export const weatherLabel = { sun: "Ensoleillé", cloudy: "Nuageux", rain: "Pluie" }; +export const prestigeLabel = "Prestige (reset avec bonus permanent)"; +export const prestigeButton = "Réinitialiser (Prestige +%d)"; +export const prestigeHint = "Réinitialise tout et ajoute un bonus permanent de revenus. Coût min. : %d pièces."; +export const visitorsLabel = "Visiteurs"; +export const musicLabel = "Musique"; +export const incidentLabel = { + thirst: "Soif", + bin: "Poubelle pleine", + bench: "Banc requis", + animalFar: "Animal trop loin", + photo: "Envie de photo", +}; +export const incidentBubbleAria = "Résoudre l'incident (cliquer pour bonus)"; +export const sellZoneTitle = "Vente : glissez un animal ici (envoi à un autre zoo)"; +export const worldMapTitle = "Carte du monde"; +export const sellZoneShortLabel = "Vente"; +export const restartButton = "Recommencer"; +export const helpRestart = "Recommence une nouvelle partie sans bonus de prestige."; +export const questsTitle = "Objectifs du jour"; + +export const salesPanelAriaLabel = "Enchères et ventes"; +export const salesPanelMySales = "Mes ventes"; +export const salesPanelToRecover = "À récupérer"; +export const salesPanelAuctions = "Enchères"; +export const salesBtnAccept = "Accepter"; +export const salesBtnReject = "Refuser"; +export const salesBtnDeliver = "Récupérer"; +export const salesBtnBid = "Enchérir"; +export const salesPendingValidation = "En attente de validation"; +export const salesValidationInMinutes = "Validation dans %s min"; +export const salesBidInputAriaLabel = "Montant de l'enchère"; +export const noFreeNursery = "Plus de place en nurserie"; +export const noFreeReception = "Plus de place à l'accueil"; + +/** Auto-mode profile families (1–5). */ +export const autoProfileFamilyLabel = { + 1: "Conservateurs", + 2: "Éleveurs", + 3: "Commerçants", + 4: "Expansionnistes", + 5: "Scientifiques", +}; + +/** + * Auto-mode specialisation label by profile id (1–50). Keys are string numbers. + * @type {Record} + */ +export const autoProfileSpecialisationLabel = (() => { + const out = {}; + const familyNames = ["Conservateur", "Éleveur", "Commerçant", "Expansionniste", "Scientifique"]; + for (let f = 0; f < 5; f++) { + for (let i = 1; i <= 10; i++) { + const id = f * 10 + i; + out[String(id)] = `${familyNames[f]} ${i}`; + } + } + return out; +})(); + +/** Auto-mode priorities text by profile id (1–50). Keys are string numbers. */ +export const autoProfilePrioritiesLabel = (() => { + const out = {}; + for (let id = 1; id <= 50; id++) out[String(id)] = `Priorités profil ${id}`; + return out; +})(); + +/** Auto-mode risks text by profile id (1–50). Keys are string numbers. */ +export const autoProfileRisksLabel = (() => { + const out = {}; + for (let id = 1; id <= 50; id++) out[String(id)] = `Risques profil ${id}`; + return out; +})(); + +export const autoProfilePickerTitle = "Choisir le profil du mode auto"; +export const autoProfilePickerFamilyStep = "Choisir une famille"; +export const autoProfilePickerSpecialisationStep = "Choisir une spécialisation"; +export const autoProfileActivate = "Activer avec ce profil"; +export const autoProfileCancel = "Annuler"; diff --git a/web/js/time-weather.js b/web/js/time-weather.js new file mode 100644 index 0000000..dd79aa6 --- /dev/null +++ b/web/js/time-weather.js @@ -0,0 +1,39 @@ +import { GameConfig } from "./config.js"; + +/** + * @param {import("./types.js").GameState} state + * @param {number} dtWallSeconds + */ +export function tickTime(state, dtWallSeconds) { + const dayLength = GameConfig.Time.DayLengthSeconds; + const phase = (state.timeOfDay ?? 6) + (dtWallSeconds * 24) / dayLength; + state.timeOfDay = phase >= 24 ? phase - 24 : phase; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {string} + */ +export function tickWeather(state, nowUnix) { + const last = state.lastWeatherChangeAt ?? 0; + if (nowUnix - last < GameConfig.Weather.ChangeIntervalSeconds) return state.weather ?? "sun"; + state.lastWeatherChangeAt = nowUnix; + const r = Math.random(); + if (r < GameConfig.Weather.RainChance) state.weather = "rain"; + else if (r < GameConfig.Weather.RainChance + GameConfig.Weather.CloudyChance) state.weather = "cloudy"; + else state.weather = "sun"; + return state.weather; +} + +/** + * @param {number} timeOfDay 0..24 + * @returns {{ phase: string, intensity: number }} + */ +export function getTimePhase(timeOfDay) { + const t = timeOfDay % 24; + if (t >= 5 && t < 8) return { phase: "dawn", intensity: (t - 5) / 3 }; + if (t >= 8 && t < 18) return { phase: "day", intensity: 1 }; + if (t >= 18 && t < 21) return { phase: "dusk", intensity: (21 - t) / 3 }; + return { phase: "night", intensity: 1 }; +} diff --git a/web/js/trade.js b/web/js/trade.js new file mode 100644 index 0000000..e28c1fb --- /dev/null +++ b/web/js/trade.js @@ -0,0 +1,132 @@ +import { LootTables } from "./loot-tables.js"; +import { getIncomeMultiplier } from "./mutation-rules.js"; +import { getSellValue } from "./economy.js"; +import { cellKey } from "./grid-utils.js"; +import { getBlockKeysFromCell } from "./placement.js"; +import { GameConfig } from "./config.js"; +import { getReproductionScore } from "./reproduction.js"; + +/** + * Put a mature baby from nursery on sale (phase 10). Removes it from pendingBabies and clears the nursery cell. + * @param {import("./types.js").GameState} state + * @param {string} nurseryCellKey + * @returns {[boolean, string]} [ok, listingId or reason] + */ +export function addMatureBabyToSale(state, nurseryCellKey) { + const now = Math.floor(Date.now() / 1000); + const pendingBabies = state.pendingBabies ?? []; + const idx = pendingBabies.findIndex( + (p) => p.nurseryCellKey === nurseryCellKey && now >= p.readyAt + ); + if (idx < 0) { + const first = pendingBabies.find((p) => p.nurseryCellKey === nurseryCellKey); + return [false, first ? "BabyNotMature" : "NoBabyInNursery"]; + } + const baby = pendingBabies[idx]; + state.pendingBabies = pendingBabies.filter((_, i) => i !== idx); + const cell = state.grid.cells[nurseryCellKey]; + if (cell && cell.kind === "nursery") cell.tokenId = undefined; + state.saleListings = state.saleListings ?? []; + const duration = GameConfig.Sale?.ListingDurationSeconds ?? 3600; + const price = GameConfig.Sale?.DefaultPrice ?? 50; + const listingId = `sale_${state.nextTokenId}`; + state.nextTokenId += 1; + state.saleListings.push({ + id: listingId, + zooId: state.myZooId ?? "player", + animalId: baby.animalId, + isBaby: true, + price, + endAt: now + duration, + reproductionScoreAtSale: getReproductionScore(state), + }); + state.lastEvolutionAt = now; + return [true, listingId]; +} + +/** + * Put a ready reception animal on sale (phase 10). Removes it from receptionAnimals and clears the reception cell. + * @param {import("./types.js").GameState} state + * @param {string} receptionCellKey + * @returns {[boolean, string]} [ok, listingId or reason] + */ +export function addReceptionAnimalToSale(state, receptionCellKey) { + const now = Math.floor(Date.now() / 1000); + const receptionAnimals = state.receptionAnimals ?? []; + const idx = receptionAnimals.findIndex( + (r) => r.receptionCellKey === receptionCellKey && now >= r.readyAt + ); + if (idx < 0) { + const first = receptionAnimals.find((r) => r.receptionCellKey === receptionCellKey); + return [false, first ? "AnimalNotReady" : "NoAnimalInReception"]; + } + const rec = receptionAnimals[idx]; + state.receptionAnimals = receptionAnimals.filter((_, i) => i !== idx); + state.saleListings = state.saleListings ?? []; + const duration = GameConfig.Sale?.ListingDurationSeconds ?? 3600; + const price = GameConfig.Sale?.DefaultPrice ?? 50; + const listingId = `sale_${state.nextTokenId}`; + state.nextTokenId += 1; + state.saleListings.push({ + id: listingId, + zooId: state.myZooId ?? "player", + animalId: rec.animalId, + isBaby: false, + price, + endAt: now + duration, + reproductionScoreAtSale: rec.reproductionScoreAtSale ?? getReproductionScore(state), + }); + state.lastEvolutionAt = now; + return [true, listingId]; +} + +/** + * Remove expired sale listings. If listing was a baby (isBaby), increment deathCountRecent (bébé invendu meurt). + * Call from game loop each tick. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function tickSaleListings(state, nowUnix) { + const listings = state.saleListings ?? []; + const kept = []; + let babyDeaths = 0; + for (const listing of listings) { + if (nowUnix < listing.endAt) { + kept.push(listing); + } else if (listing.isBaby) { + babyDeaths += 1; + } + } + state.saleListings = kept; + if (babyDeaths > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babyDeaths; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, number | string]} + */ +export function sellAnimalToNpc(state, x, y) { + const key = cellKey(x, y); + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== "animal") return [false, "NoAnimal"]; + const animalDef = LootTables.Animals[cell.id]; + if (animalDef === null || animalDef === undefined) throw new Error("TradeService: unknown animal"); + const blockKeys = getBlockKeysFromCell(state, x, y); + const originKey = blockKeys[0]; + const originCell = state.grid.cells[originKey]; + if (originCell === null || originCell === undefined || originCell.kind !== "animal") return [false, "NoAnimal"]; + const mutationMultiplier = getIncomeMultiplier(originCell.mutation); + const sellValue = getSellValue( + animalDef.baseIncomePerSecond, + originCell.level, + mutationMultiplier, + animalDef.sellFactor + ); + for (const k of blockKeys) delete state.grid.cells[k]; + state.coins += sellValue; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + if (state.stats) state.stats.animalsSold = (state.stats.animalsSold ?? 0) + 1; + return [true, sellValue]; +} diff --git a/web/js/types.js b/web/js/types.js new file mode 100644 index 0000000..e651e73 --- /dev/null +++ b/web/js/types.js @@ -0,0 +1,82 @@ +/** + * @typedef {{ kind: "egg", eggType: string, tokenId: number, hatchAt: number, seed: number }} EggCell + * @typedef {{ kind: "animal", id: string, mutation: string, level: number, placedAt: number, lastVisitedAt?: number, lastFedAt?: number, originKey?: string, cellsWide?: number, cellsHigh?: number, fromOtherZoo?: boolean }} AnimalCell + * @typedef {{ kind: "school", level: number }} SchoolCell + * @typedef {{ kind: "nursery", tokenId?: number, level?: number }} NurseryCell + * @typedef {{ kind: "souvenirShop", level: number }} SouvenirShopCell + * @typedef {{ kind: "research", level: number }} ResearchCell + * @typedef {{ kind: "billeterie", level: number }} BilleterieCell + * @typedef {{ kind: "food", level: number }} FoodCell + * @typedef {{ kind: "reception", level: number }} ReceptionCell + * @typedef {{ kind: "biomeChangeColor", level: number }} BiomeChangeColorCell + * @typedef {{ kind: "biomeChangeTemp", level: number }} BiomeChangeTempCell + * @typedef {EggCell | AnimalCell | SchoolCell | NurseryCell | SouvenirShopCell | ResearchCell | BilleterieCell | FoodCell | ReceptionCell | BiomeChangeColorCell | BiomeChangeTempCell} Cell + * + * @typedef {{ id: string, animalId: string, nurseryCellKey: string, readyAt: number, fromOtherZoo?: boolean }} PendingBaby + * @typedef {{ id: string, animalId: string, receptionCellKey: string, readyAt: number, originZooId?: string, reproductionScoreAtSale?: number }} ReceptionAnimal + * @typedef {{ id: string, zooId: string, babyId?: string, animalId?: string, isBaby: boolean, price: number, endAt: number, reproductionScoreAtSale?: number, serverId?: string, bestBidAmount?: number, bestBidderZooId?: string, status?: "active"|"sold"|"expired"|"rejected"|"validated", validatedAt?: number | null }} SaleListing + * + * @typedef {{ arrivedAt: number, incidentType?: "thirst"|"bin"|"bench"|"animalFar"|"photo", incidentSince?: number }} VisitorEntry + * + * @typedef {{ id: string, descriptionKey: string, target: number, current: number, reward: number, done: boolean }} Quest + * + * @typedef {{ coins: number, plotLevel: number, conveyorLevel: number, truckLevel: number, profile: "fast"|"slow"|"balanced", lastTickAt: number }} BotState + * @typedef {{ id: string, name: string, x: number, y: number, animalWeights: Record, botState?: BotState }} WorldZooEntry + * + * @typedef {{ + * version: number, + * specVersion?: number, + * coins: number, + * conveyorLevel: number, + * plotLevel: number, + * truckLevel?: number, + * grid: { width: number, height: number, cells: Record }, + * pendingEggTokens: Array<{ tokenId: number, eggType: string, boughtAt: number }>, + * nextTokenId: number, + * conveyorOffers: Array<{ eggType: string, price: number, zooId?: string }>, + * lastOfferRefreshAt: number, + * worldZoos?: Array, + * truckSale?: { toZooId: string, startAt: number }, + * eggPurchaseTruck?: { eggType: string, fromZooId: string, toZooId: string, startAt: number }, + * worldTruckSales?: Array<{ fromZooId: string, toZooId: string, startAt: number }>, + * lastEvolutionAt?: number, + * laboratoryOffer?: { eggType: string, price: number, endAt: number } | null, + * prestigeLevel?: number, + * timeOfDay?: number, + * weather?: string, + * lastWeatherChangeAt?: number, + * quests?: Quest[], + * lastQuestDay?: string, + * stats?: { eggsPlaced: number, animalsSold: number, conveyorUpgrades: number, plotUpgrades: number, truckUpgrades?: number }, + * mapZoom?: number, + * mapPanX?: number, + * mapPanY?: number, + * worldMapLevel?: number, + * autoMode?: boolean, + * autoModeProfile?: "fast"|"slow"|"balanced", + * autoModeProfileId?: number, + * autoProfilePickerOpen?: boolean, + * autoProfilePickerFamily?: number, + * lastPlayerAutoTickAt?: number, + * myZooId?: string, + * playerName?: string, + * playerX?: number, + * playerY?: number, + * researchPoints?: number, + * pendingBabies?: PendingBaby[], + * receptionAnimals?: ReceptionAnimal[], + * saleListings?: SaleListing[], + * salesFromApi?: { asSeller: Array<{ 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 }>, asBuyerUndelivered: Array<{ id: string, animal_id: string, is_baby: boolean, initial_price: number, status?: string, validated_at?: string | null, reproduction_score_at_sale?: number }>, active: Array<{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: string, best_bid_amount?: number }> }, + * deathCountRecent?: number, + * birthCount?: number, + * feedingRate?: number, + * reproductionScore?: number, + * attractivityScore?: number, + * attractivityScore?: number, + * reproductionTimers?: Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>, + * visitorArrivals?: VisitorEntry[], + * attractivityBonusFromIncidents?: number, + * }} GameState + */ + +export default {}; diff --git a/web/js/ui.js b/web/js/ui.js new file mode 100644 index 0000000..3f25085 --- /dev/null +++ b/web/js/ui.js @@ -0,0 +1,1606 @@ +import { tryBuyEgg, tryPlaceEgg, tryUpgradePlot, tryUpgradeWorldMap, tryBuyLabEgg, getNurseryCellKeysOrdered, tryBuyBaby, tryBuyAnimal, placeMatureBabyOnCell, placeReceptionAnimalOnCell, addPendingBaby, addReceptionAnimal } from "./zoo.js"; +import { tryUpgrade as _tryUpgradeConveyor, refreshOffers, pickSaleTargetZoo, getSkillLevel, tryUpgradeSchool, tryUpgradeTruck } from "./conveyor.js"; +import { sellAnimalToNpc, addMatureBabyToSale, addReceptionAnimalToSale } from "./trade.js"; +import { getPlotUpgradeCost, getSchoolUpgradeCost, getTruckUpgradeCost, getWorldMapUpgradeResearchCost, getNurseryBuildCost, getSouvenirShopBuildCost, getNurseryUpgradeCost, getSouvenirShopUpgradeCost, getResearchBuildCost, getResearchUpgradeCost, getBilleterieBuildCost, getBilleterieUpgradeCost, getFoodBuildCost, getFoodUpgradeCost, getReceptionBuildCost, getReceptionUpgradeCost, getBiomeChangeColorBuildCost, getBiomeChangeColorUpgradeCost, getBiomeChangeTempBuildCost, getBiomeChangeTempUpgradeCost } from "./economy.js"; +import { moveCell, tryBuildNursery, tryBuildSouvenirShop, tryUpgradeNursery, tryUpgradeSouvenirShop, tryBuildResearch, tryUpgradeResearch, tryBuildBilleterie, tryUpgradeBilleterie, tryBuildFood, tryUpgradeFood, tryBuildReception, tryUpgradeReception, tryBuildBiomeChangeColor, tryUpgradeBiomeChangeColor, tryBuildBiomeChangeTemp, tryUpgradeBiomeChangeTemp } from "./placement.js"; +import { getCellBiome, getDisplayBiome, getDisplayTemperature, getTemperatureBand } from "./biome-rules.js"; +import { getVisitorCount } from "./income.js"; +import { getTimePhase } from "./time-weather.js"; +import { canPrestige, doPrestige } from "./prestige.js"; +import { playSound, setMusicEnabled, isMusicEnabled } from "./audio.js"; +import { + t, + eggTypeLabel, + animalLabel, + errorMessage, + questDescription, + timePhaseLabel, + weatherLabel, + prestigeLabel, + prestigeButton as _prestigeButton, + prestigeHint, + visitorsLabel, + musicLabel, + sellZoneTitle, + sellZoneShortLabel, + restartButton, + helpRestart, + questsTitle, + salesPanelAriaLabel, + salesPanelMySales, + salesPanelToRecover, + salesPanelAuctions, + salesBtnAccept, + salesBtnReject, + salesBtnDeliver, + salesBtnBid, + salesPendingValidation, + salesValidationInMinutes, + salesBidInputAriaLabel, + noFreeNursery, + noFreeReception, + autoProfileFamilyLabel, + autoProfileSpecialisationLabel, + autoProfilePickerTitle, + autoProfilePickerFamilyStep, + autoProfilePickerSpecialisationStep, + autoProfileCancel, +} from "./texts-fr.js"; +import { getProfilesByFamily, AUTO_MODE_FAMILY_IDS } from "./auto-mode-profiles.js"; +import { GameConfig } from "./config.js"; +import { defaultAnimalWeights } from "./state.js"; +import { getApiBase, createSale, getSales, acceptSale, rejectSale, placeBid, deliverSale, mapServerListingToClient } from "./api-client.js"; + +const EGG_EMOJI = "🥚"; +const EMOJI_BY_COLOR = ["🐰", "🦌", "🐸", "🦎", "🐢", "🐬", "🦭", "🐟", "🦈", "🐳", "🦅", "🐺", "🐻", "🦊", "🐗"]; +const animalEmoji = {}; +for (let c = 0; c < 15; c++) { + for (let r = 0; r < 5; r++) { + animalEmoji[`c${c}_r${r}`] = EMOJI_BY_COLOR[c] ?? "🐾"; + } +} + +/** + * @param {string} labelContent + * @param {string} tooltipText + * @returns {HTMLElement} + */ +function makeHelpWrap(labelContent, tooltipText) { + const wrap = document.createElement("div"); + wrap.className = "help-wrap"; + const label = document.createElement("span"); + label.textContent = labelContent; + const icon = document.createElement("span"); + icon.className = "help-icon"; + icon.setAttribute("aria-label", "Aide"); + icon.textContent = "?"; + const bubble = document.createElement("div"); + bubble.className = "tooltip-bubble"; + bubble.textContent = tooltipText; + wrap.append(label, icon, bubble); + return wrap; +} + +/** + * @param {HTMLElement} parent + * @param {string} titleText + * @param {string} helpText + */ +function _addSectionTitle(parent, titleText, helpText) { + const section = document.createElement("div"); + section.className = "section-with-help"; + const h2 = document.createElement("h2"); + h2.textContent = titleText; + section.appendChild(h2); + section.appendChild(makeHelpWrap("", helpText)); + parent.appendChild(section); +} + +/** + * @param {HTMLElement} root + * @param {{ state: import("./types.js").GameState, setState: () => void, getLastHatched: () => Array<{ x: number, y: number }>, onRestart?: () => void, updateState?: (partial: Partial) => void }} opts + * @returns {void} + */ +export function render(root, opts) { + const { state, setState, getLastHatched, onRestart, updateState } = opts; + const getHatched = getLastHatched ?? (() => []); + const phase = getTimePhase(state.timeOfDay ?? 6); + const weather = state.weather || "sun"; + document.body.classList.remove("bg-phase-dawn", "bg-phase-day", "bg-phase-dusk", "bg-phase-night", "bg-weather-sun", "bg-weather-cloudy", "bg-weather-rain"); + document.body.classList.add(`bg-phase-${phase.phase}`, `bg-weather-${weather}`); + root.innerHTML = ""; + const selected = { x: 1, y: 1 }; + const pendingTokenByEggType = {}; + let selectedTokenId = null; + let emptyCellChoice = null; + const errorMsg = { current: "" }; + let lastActionWasDrop = false; + let sellZoneJustDropped = false; + + const clampSelection = () => { + selected.x = Math.max(1, Math.min(state.grid.width, selected.x)); + selected.y = Math.max(1, Math.min(state.grid.height, selected.y)); + }; + + const setError = (msg) => { + errorMsg.current = msg; + if (errEl) { + errEl.textContent = msg; + errEl.hidden = !msg; + } + }; + + const gameBar = document.createElement("div"); + gameBar.className = "game-bar"; + gameBar.setAttribute("aria-label", "Barre du jeu"); + const gameBarTitleWrap = document.createElement("div"); + gameBarTitleWrap.className = "game-bar-title-wrap"; + const gameBarTitle = document.createElement("h1"); + gameBarTitle.className = "game-bar-title"; + gameBarTitle.textContent = t.title; + const titleHelp = makeHelpWrap("", t.helpStatus); + titleHelp.querySelector(".tooltip-bubble").classList.add("below"); + gameBarTitleWrap.append(gameBarTitle, titleHelp); + gameBar.appendChild(gameBarTitleWrap); + const statusBar = document.createElement("div"); + statusBar.className = "status-bar"; + statusBar.setAttribute("aria-label", "Indicateurs"); + function addStatusItem(iconEmoji, tooltipText, initialValue) { + const item = document.createElement("span"); + item.className = "status-bar-item"; + const icon = document.createElement("span"); + icon.className = "status-bar-icon"; + icon.setAttribute("aria-hidden", "true"); + icon.title = tooltipText; + icon.textContent = iconEmoji; + const value = document.createElement("span"); + value.className = "status-bar-value"; + value.textContent = initialValue; + item.append(icon, value); + return { item, valueEl: value }; + } + const statusBarCoins = addStatusItem("🪙", "Pièces", "0"); + const statusBarPlot = addStatusItem("📐", "Parcelle", "1"); + const statusBarCell = addStatusItem("📍", "Case sélectionnée", "1 1"); + const statusBarSkill = addStatusItem("🎓", "Compétences", "1"); + const statusBarVisitors = addStatusItem("👤", visitorsLabel, "0"); + const statusBarOffers = addStatusItem("🥚", "Œufs à vendre", "0"); + const statusBarTimeWeather = addStatusItem("🌤️", "Météo et heure", "—"); + statusBar.append( + statusBarCoins.item, statusBarPlot.item, statusBarCell.item, statusBarSkill.item, + statusBarVisitors.item, statusBarOffers.item, statusBarTimeWeather.item + ); + gameBar.appendChild(statusBar); + const gameBarActions = document.createElement("div"); + gameBarActions.className = "game-bar-actions"; + const viewSwitcherWrap = document.createElement("div"); + viewSwitcherWrap.className = "game-bar-view-switcher"; + viewSwitcherWrap.setAttribute("aria-label", "Zoo ou carte du monde"); + const viewToggleBtn = document.createElement("button"); + viewToggleBtn.className = "game-bar-btn game-bar-view-btn"; + viewToggleBtn.type = "button"; + viewToggleBtn.id = "view-toggle"; + viewToggleBtn.setAttribute("aria-label", "Afficher la carte du monde"); + viewToggleBtn.title = "Carte du monde (cliquer pour afficher)"; + viewToggleBtn.textContent = "🗺️"; + viewSwitcherWrap.appendChild(viewToggleBtn); + function setViewToggleIcon(isZooActive) { + viewToggleBtn.textContent = isZooActive ? "🦒" : "🗺️"; + viewToggleBtn.setAttribute("aria-label", isZooActive ? "Afficher la carte du monde" : "Afficher la carte du zoo"); + viewToggleBtn.title = isZooActive ? "Carte du monde (cliquer pour afficher)" : "Carte du zoo (cliquer pour afficher)"; + } + viewToggleBtn.addEventListener("click", () => { + const showZoo = !panelZoo.classList.contains("active"); + if (showZoo) { + panelZoo.classList.add("active"); + panelWorld.classList.remove("active"); + setViewToggleIcon(true); + } else { + panelWorld.classList.add("active"); + panelZoo.classList.remove("active"); + setViewToggleIcon(false); + if (getApiBase()) { + getSales().then((data) => { state.salesFromApi = data; setState(); }).catch(() => {}); + } + } + }); + setViewToggleIcon(true); + const musicBtn = document.createElement("button"); + musicBtn.className = "game-bar-btn game-bar-btn-music" + (isMusicEnabled() ? "" : " muted"); + musicBtn.type = "button"; + musicBtn.setAttribute("aria-label", musicLabel); + musicBtn.title = musicLabel; + musicBtn.textContent = "🎵"; + musicBtn.addEventListener("click", () => { + const next = !isMusicEnabled(); + setMusicEnabled(next); + try { + localStorage.setItem("builazoo_music", next ? "1" : "0"); + } catch (_) { + // ignore localStorage + } + musicBtn.classList.toggle("muted", !next); + }); + gameBarActions.appendChild(musicBtn); + const autoModeBtn = document.createElement("button"); + autoModeBtn.className = "game-bar-btn game-bar-btn-auto-mode"; + autoModeBtn.type = "button"; + autoModeBtn.id = "auto-mode-btn"; + autoModeBtn.setAttribute("aria-pressed", state.autoMode ? "true" : "false"); + autoModeBtn.title = state.autoMode ? "Mode automatique (désactiver)" : "Mode automatique (activer)"; + autoModeBtn.setAttribute("aria-label", state.autoMode ? "Mode automatique actif" : "Activer le mode automatique"); + autoModeBtn.textContent = state.autoMode ? "🤖" : "✋"; + autoModeBtn.addEventListener("click", () => { + if (state.autoMode) { + updateState({ autoMode: false }); + } else { + updateState({ autoProfilePickerOpen: true, autoProfilePickerFamily: undefined }); + } + }); + gameBarActions.appendChild(autoModeBtn); + if (state.autoProfilePickerOpen) { + const pickerWrap = document.createElement("div"); + pickerWrap.className = "auto-profile-picker-wrap"; + pickerWrap.setAttribute("role", "dialog"); + pickerWrap.setAttribute("aria-label", autoProfilePickerTitle); + const pickerTitle = document.createElement("div"); + pickerTitle.className = "auto-profile-picker-title"; + pickerTitle.textContent = autoProfilePickerTitle; + pickerWrap.appendChild(pickerTitle); + const familyId = state.autoProfilePickerFamily; + if (familyId === null || familyId === undefined) { + const stepLabel = document.createElement("div"); + stepLabel.className = "auto-profile-picker-step"; + stepLabel.textContent = autoProfilePickerFamilyStep; + pickerWrap.appendChild(stepLabel); + const familyBtns = document.createElement("div"); + familyBtns.className = "auto-profile-picker-families"; + for (const fid of AUTO_MODE_FAMILY_IDS) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "auto-profile-picker-family-btn"; + btn.textContent = autoProfileFamilyLabel[fid] ?? `Famille ${fid}`; + btn.addEventListener("click", () => updateState({ autoProfilePickerFamily: fid })); + familyBtns.appendChild(btn); + } + pickerWrap.appendChild(familyBtns); + } else { + const stepLabel = document.createElement("div"); + stepLabel.className = "auto-profile-picker-step"; + stepLabel.textContent = autoProfilePickerSpecialisationStep; + pickerWrap.appendChild(stepLabel); + const profiles = getProfilesByFamily(familyId); + const specWrap = document.createElement("div"); + specWrap.className = "auto-profile-picker-specialisations"; + for (const prof of profiles) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "auto-profile-picker-spec-btn"; + btn.textContent = autoProfileSpecialisationLabel[String(prof.id)] ?? `Profil ${prof.id}`; + btn.addEventListener("click", () => { + updateState({ + autoModeProfileId: prof.id, + autoMode: true, + autoProfilePickerOpen: false, + autoProfilePickerFamily: undefined, + }); + }); + specWrap.appendChild(btn); + } + pickerWrap.appendChild(specWrap); + } + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "auto-profile-picker-cancel"; + cancelBtn.textContent = autoProfileCancel; + cancelBtn.addEventListener("click", () => updateState({ autoProfilePickerOpen: false, autoProfilePickerFamily: undefined })); + pickerWrap.appendChild(cancelBtn); + gameBarActions.appendChild(pickerWrap); + } + gameBarActions.insertBefore(viewSwitcherWrap, gameBarActions.firstChild); + const prestigeBtn = document.createElement("button"); + prestigeBtn.className = "game-bar-btn game-bar-btn-prestige"; + prestigeBtn.type = "button"; + prestigeBtn.setAttribute("aria-label", prestigeLabel); + prestigeBtn.title = prestigeHint; + prestigeBtn.textContent = "⭐"; + gameBarActions.appendChild(prestigeBtn); + const restartBtn = document.createElement("button"); + restartBtn.className = "game-bar-btn game-bar-btn-restart"; + restartBtn.type = "button"; + restartBtn.setAttribute("aria-label", restartButton); + restartBtn.title = helpRestart; + restartBtn.textContent = "🔄"; + if (onRestart) { + restartBtn.addEventListener("click", () => onRestart()); + } else { + restartBtn.disabled = true; + } + gameBarActions.appendChild(restartBtn); + const questWrap = document.createElement("div"); + questWrap.className = "game-bar-quest-wrap"; + const questBtn = document.createElement("button"); + questBtn.className = "game-bar-btn game-bar-btn-quest"; + questBtn.type = "button"; + questBtn.setAttribute("aria-label", questsTitle); + questBtn.setAttribute("aria-expanded", "false"); + questBtn.title = questsTitle; + questBtn.textContent = "📋"; + const questDropdown = document.createElement("div"); + questDropdown.className = "quest-dropdown"; + questDropdown.setAttribute("role", "dialog"); + questDropdown.setAttribute("aria-label", questsTitle); + const questDropdownTitle = document.createElement("div"); + questDropdownTitle.className = "quest-dropdown-title"; + questDropdownTitle.textContent = questsTitle; + questDropdown.appendChild(questDropdownTitle); + const questListEl = document.createElement("div"); + questListEl.className = "quest-list"; + questDropdown.appendChild(questListEl); + questWrap.appendChild(questBtn); + questWrap.appendChild(questDropdown); + questBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const open = questWrap.classList.toggle("open"); + questBtn.setAttribute("aria-expanded", String(open)); + }); + document.addEventListener("click", () => { + questWrap.classList.remove("open"); + questBtn.setAttribute("aria-expanded", "false"); + }); + questDropdown.addEventListener("click", (e) => e.stopPropagation()); + gameBarActions.appendChild(questWrap); + gameBar.appendChild(gameBarActions); + + const errEl = document.createElement("div"); + errEl.className = "error-msg"; + errEl.hidden = true; + + const tabsWrap = document.createElement("div"); + tabsWrap.className = "tabs-wrap"; + tabsWrap.setAttribute("aria-label", "Carte du zoo et carte du monde"); + const tabContent = document.createElement("div"); + tabContent.className = "tabs-content"; + const panelZoo = document.createElement("div"); + panelZoo.className = "tab-panel active"; + panelZoo.id = "tab-panel-zoo"; + panelZoo.setAttribute("role", "tabpanel"); + panelZoo.setAttribute("aria-labelledby", "view-toggle"); + const panelWorld = document.createElement("div"); + panelWorld.className = "tab-panel"; + panelWorld.id = "tab-panel-world"; + panelWorld.setAttribute("role", "tabpanel"); + panelWorld.setAttribute("aria-labelledby", "view-toggle"); + + const worldMapWrap = document.createElement("div"); + worldMapWrap.className = "world-map-wrap world-map-wrap-square"; + const worldMapEl = document.createElement("div"); + worldMapEl.className = "world-map world-map-biomes"; + const mapLevel = state.worldMapLevel ?? 1; + const zoom = Math.min(0.65 + (mapLevel - 1) * 0.2, 1.45); + worldMapEl.style.transformOrigin = "50% 50%"; + worldMapEl.style.transform = `scale(${zoom})`; + worldMapWrap.appendChild(worldMapEl); + const worldMapTruckEl = document.createElement("div"); + worldMapTruckEl.className = "world-map-truck"; + worldMapTruckEl.setAttribute("aria-hidden", "true"); + worldMapWrap.appendChild(worldMapTruckEl); + const worldMapNpcTrucksEl = document.createElement("div"); + worldMapNpcTrucksEl.className = "world-map-trucks"; + worldMapNpcTrucksEl.setAttribute("aria-hidden", "true"); + worldMapWrap.appendChild(worldMapNpcTrucksEl); + panelWorld.appendChild(worldMapWrap); + + const worldMapActions = document.createElement("div"); + worldMapActions.className = "world-map-actions"; + const worldMapUpgradeZone = document.createElement("div"); + worldMapUpgradeZone.className = "world-map-upgrade-zone"; + worldMapUpgradeZone.setAttribute("aria-label", "Agrandir la carte"); + worldMapUpgradeZone.title = "Agrandir la carte"; + worldMapUpgradeZone.innerHTML = "🗺️Agrandir carte"; + worldMapUpgradeZone.setAttribute("role", "button"); + worldMapUpgradeZone.setAttribute("tabindex", "0"); + worldMapUpgradeZone.addEventListener("click", () => { + const [ok, reason] = tryUpgradeWorldMap(state); + if (!ok) { + setError(String(t.upgradeWorldMapFailed).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } else { + setError(""); + playSound("worldMapUpgrade"); + } + setState(); + }); + worldMapUpgradeZone.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + worldMapUpgradeZone.click(); + } + }); + worldMapActions.appendChild(worldMapUpgradeZone); + const worldMapCounters = document.createElement("div"); + worldMapCounters.className = "world-map-counters"; + worldMapCounters.setAttribute("aria-label", "Compteurs carte du monde"); + worldMapActions.appendChild(worldMapCounters); + const worldMapTruckDropZone = document.createElement("div"); + worldMapTruckDropZone.className = "world-map-truck-drop-zone"; + worldMapTruckDropZone.setAttribute("aria-label", "Camion pour acheter un œuf"); + worldMapTruckDropZone.title = "Glissez un œuf ici pour l'acheter"; + worldMapTruckDropZone.innerHTML = "🚚Acheter œuf"; + worldMapTruckDropZone.addEventListener("dragover", (e) => { + e.preventDefault(); + const hasOffer = e.dataTransfer.types.includes("application/x-builazoo-eggtype") + || e.dataTransfer.types.includes("application/x-builazoo-baby-offer") + || e.dataTransfer.types.includes("application/x-builazoo-animal-offer"); + e.dataTransfer.dropEffect = hasOffer ? "copy" : "none"; + if (hasOffer) worldMapTruckDropZone.classList.add("dragover"); + }); + worldMapTruckDropZone.addEventListener("dragleave", () => worldMapTruckDropZone.classList.remove("dragover")); + worldMapTruckDropZone.addEventListener("drop", (e) => { + e.preventDefault(); + worldMapTruckDropZone.classList.remove("dragover"); + const babyOffer = e.dataTransfer.getData("application/x-builazoo-baby-offer"); + if (babyOffer) { + const [animalId, priceStr] = babyOffer.split(":"); + const price = Number(priceStr) || 80; + const [ok, result] = tryBuyBaby(state, animalId, price); + if (!ok) setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); + else setError(""); + playSound(ok ? "buy" : "error"); + setState(); + return; + } + const animalOffer = e.dataTransfer.getData("application/x-builazoo-animal-offer"); + if (animalOffer) { + const [animalId, priceStr] = animalOffer.split(":"); + const price = Number(priceStr) || 120; + const [ok, result] = tryBuyAnimal(state, animalId, price); + if (!ok) setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); + else setError(""); + playSound(ok ? "buy" : "error"); + setState(); + return; + } + const eggType = e.dataTransfer.getData("application/x-builazoo-eggtype"); + const toZooId = e.dataTransfer.getData("application/x-builazoo-offer-zooid") || "player"; + if (!eggType) return; + const [ok, result] = tryBuyEgg(state, eggType); + if (!ok) { + setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setError(""); + playSound("buy"); + state.eggPurchaseTruck = { eggType, fromZooId: "player", toZooId, startAt: Date.now() }; + } + setState(); + }); + worldMapActions.appendChild(worldMapTruckDropZone); + panelWorld.appendChild(worldMapActions); + + const gridWrap = document.createElement("div"); + gridWrap.className = "grid-wrap"; + const gridEl = document.createElement("div"); + gridEl.className = "grid"; + gridWrap.appendChild(gridEl); + const plotUpgradeZone = document.createElement("div"); + plotUpgradeZone.className = "plot-upgrade-zone"; + plotUpgradeZone.setAttribute("aria-label", t.upgradePlot); + plotUpgradeZone.title = t.upgradePlot; + plotUpgradeZone.innerHTML = "📐Agrandir zoo"; + plotUpgradeZone.setAttribute("role", "button"); + plotUpgradeZone.setAttribute("tabindex", "0"); + plotUpgradeZone.addEventListener("click", () => { + const [ok, reason] = tryUpgradePlot(state); + if (!ok) { + setError(String(t.upgradePlotFailed).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } else { + setError(""); + playSound("plotUpgrade"); + } + setState(); + }); + plotUpgradeZone.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + plotUpgradeZone.click(); + } + }); + gridWrap.appendChild(plotUpgradeZone); + const sellZone = document.createElement("div"); + sellZone.className = "sell-zone"; + sellZone.setAttribute("aria-label", sellZoneTitle); + sellZone.title = sellZoneTitle; + sellZone.innerHTML = "🚚" + sellZoneShortLabel + ""; + sellZone.addEventListener("dragover", (e) => { + e.preventDefault(); + const hasCell = e.dataTransfer.types.includes("text/plain"); + e.dataTransfer.dropEffect = hasCell ? "move" : "none"; + if (hasCell) sellZone.classList.add("dragover"); + }); + sellZone.addEventListener("dragleave", () => sellZone.classList.remove("dragover")); + sellZone.addEventListener("drop", (e) => { + e.preventDefault(); + sellZone.classList.remove("dragover"); + sellZoneJustDropped = true; + const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key"); + const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key"); + if (nurseryCellKey) { + const [ok, result] = addMatureBabyToSale(state, nurseryCellKey); + if (!ok) { + setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setError(""); + playSound("sell"); + const listing = state.saleListings[state.saleListings.length - 1]; + if (getApiBase() && listing) { + createSale({ animalId: listing.animalId, isBaby: true, price: listing.price, endAt: new Date(listing.endAt * 1000).toISOString(), reproductionScoreAtSale: listing.reproductionScoreAtSale }).then(({ id }) => { listing.serverId = id; setState(); }).catch(() => {}); + } + } + lastActionWasDrop = true; + setState(); + return; + } + if (receptionCellKey) { + const [ok, result] = addReceptionAnimalToSale(state, receptionCellKey); + if (!ok) { + setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setError(""); + playSound("sell"); + const listing = state.saleListings[state.saleListings.length - 1]; + if (getApiBase() && listing) { + createSale({ animalId: listing.animalId, isBaby: false, price: listing.price, endAt: new Date(listing.endAt * 1000).toISOString(), reproductionScoreAtSale: listing.reproductionScoreAtSale }).then(({ id }) => { listing.serverId = id; setState(); }).catch(() => {}); + } + } + lastActionWasDrop = true; + setState(); + return; + } + const raw = e.dataTransfer.getData("text/plain"); + if (!raw || !/^\d+_\d+$/.test(raw)) return; + const [sx, sy] = raw.split("_").map(Number); + const [ok, result] = sellAnimalToNpc(state, sx, sy); + if (!ok) { + setError(String(t.sellFailed).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setError(""); + playSound("sell"); + state.truckSale = { toZooId: pickSaleTargetZoo(state), startAt: Date.now() }; + } + lastActionWasDrop = true; + setState(); + }); + sellZone.addEventListener("click", () => { + if (sellZoneJustDropped) { + sellZoneJustDropped = false; + return; + } + const truckLevel = state.truckLevel ?? 1; + const truckMax = (GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5; + if (truckLevel >= truckMax) return; + if (state.coins < getTruckUpgradeCost(truckLevel)) return; + const [ok, reason] = tryUpgradeTruck(state); + if (!ok) { + setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } else { + setError(""); + playSound("truckUpgrade"); + } + setState(); + }); + gridWrap.appendChild(sellZone); + const visitorsLayer = document.createElement("div"); + visitorsLayer.className = "visitors-layer"; + visitorsLayer.setAttribute("aria-hidden", "true"); + gridWrap.appendChild(visitorsLayer); + panelZoo.appendChild(gridWrap); + tabContent.appendChild(panelZoo); + tabContent.appendChild(panelWorld); + tabsWrap.appendChild(errEl); + tabsWrap.appendChild(tabContent); + + gameBarActions.insertBefore(viewSwitcherWrap, gameBarActions.firstChild); + root.appendChild(gameBar); + root.appendChild(tabsWrap); + + function updateStatus() { + statusBarCoins.valueEl.textContent = String(Math.floor(state.coins)); + statusBarPlot.valueEl.textContent = String(Math.floor(state.plotLevel)); + statusBarCell.valueEl.textContent = `${Math.floor(selected.x)} ${Math.floor(selected.y)}`; + statusBarSkill.valueEl.textContent = String(Math.floor(getSkillLevel(state))); + const visitors = getVisitorCount(state); + statusBarVisitors.valueEl.textContent = String(Math.floor(visitors)); + const offersCount = (state.conveyorOffers ?? []).length; + statusBarOffers.valueEl.textContent = String(Math.floor(offersCount)); + const phase = getTimePhase(state.timeOfDay ?? 6); + const weather = weatherLabel[state.weather] ?? state.weather; + statusBarTimeWeather.valueEl.textContent = `${timePhaseLabel[phase.phase]} · ${weather}`; + statusBarTimeWeather.item.className = "status-bar-item status-bar-time-weather weather-" + (state.weather ?? "sun"); + musicBtn.classList.toggle("muted", !isMusicEnabled()); + autoModeBtn.setAttribute("aria-pressed", state.autoMode ? "true" : "false"); + autoModeBtn.title = state.autoMode ? "Mode automatique (désactiver)" : "Mode automatique (activer)"; + autoModeBtn.setAttribute("aria-label", state.autoMode ? "Mode automatique actif" : "Activer le mode automatique"); + autoModeBtn.textContent = state.autoMode ? "🤖" : "✋"; + prestigeBtn.title = String(prestigeHint).replace("%d", String(GameConfig.Prestige.MinCoinsToReset)); + prestigeBtn.disabled = !canPrestige(state); + const qList = state.quests ?? []; + questListEl.innerHTML = qList.map((q) => { + const desc = questDescription[q.descriptionKey]; + const text = desc ? String(desc).replace("%d", String(q.target)) : q.descriptionKey; + const done = q.done ? " ✓" : ""; + return `
${text} : ${q.current}/${q.target}${done}
`; + }).join(""); + } + + const WORLD_MAP_GRID_COLS = 12; + const WORLD_MAP_GRID_ROWS = 8; + + function renderWorldMap() { + worldMapEl.innerHTML = ""; + const playerZooId = state.myZooId ?? "player"; + const api = state.salesFromApi; + const myListingsFromApi = api?.asSeller ? api.asSeller.map(mapServerListingToClient) : null; + const zooListingsForPlayer = myListingsFromApi ?? (state.saleListings ?? []).filter((s) => s.zooId === playerZooId); + + const salesPanel = document.createElement("div"); + salesPanel.className = "world-map-sales-panel"; + salesPanel.setAttribute("aria-label", salesPanelAriaLabel); + if (api) { + if (api.asSeller && api.asSeller.length > 0) { + const sellerTitle = document.createElement("div"); + sellerTitle.className = "sales-panel-title"; + sellerTitle.textContent = salesPanelMySales; + salesPanel.appendChild(sellerTitle); + for (const s of api.asSeller) { + const row = document.createElement("div"); + row.className = "sales-panel-row"; + const emoji = animalEmoji[s.animal_id] ?? "🐾"; + const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id); + row.innerHTML = `${emoji}${label}${s.initial_price} 💰`; + if (s.best_bid_amount != null) { + const btnWrap = document.createElement("div"); + btnWrap.className = "sales-panel-actions"; + const acceptBtn = document.createElement("button"); + acceptBtn.type = "button"; + acceptBtn.textContent = salesBtnAccept; + acceptBtn.className = "sales-btn-accept"; + acceptBtn.addEventListener("click", () => { + acceptSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); + }); + const rejectBtn = document.createElement("button"); + rejectBtn.type = "button"; + rejectBtn.textContent = salesBtnReject; + rejectBtn.className = "sales-btn-reject"; + rejectBtn.addEventListener("click", () => { + rejectSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); + }); + btnWrap.appendChild(acceptBtn); + btnWrap.appendChild(rejectBtn); + row.appendChild(btnWrap); + } + salesPanel.appendChild(row); + } + } + if (api.asBuyerUndelivered && api.asBuyerUndelivered.length > 0) { + const buyerTitle = document.createElement("div"); + buyerTitle.className = "sales-panel-title"; + buyerTitle.textContent = salesPanelToRecover; + salesPanel.appendChild(buyerTitle); + for (const s of api.asBuyerUndelivered) { + const row = document.createElement("div"); + row.className = "sales-panel-row"; + const emoji = animalEmoji[s.animal_id] ?? "🐾"; + const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id); + row.innerHTML = `${emoji}${label}`; + const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0; + const nowMs = Date.now(); + const pendingValidation = s.status === "sold" && validatedAtMs > nowMs; + if (pendingValidation) { + const remainingMin = Math.ceil((validatedAtMs - nowMs) / 60000); + const pendingEl = document.createElement("span"); + pendingEl.className = "sales-pending-validation"; + pendingEl.setAttribute("aria-label", salesPendingValidation); + pendingEl.textContent = `⏳ ${salesValidationInMinutes.replace("%s", String(remainingMin))}`; + row.appendChild(pendingEl); + } + const deliverBtn = document.createElement("button"); + deliverBtn.type = "button"; + deliverBtn.textContent = salesBtnDeliver; + deliverBtn.className = "sales-btn-deliver"; + deliverBtn.disabled = pendingValidation; + deliverBtn.addEventListener("click", () => { + const [ok, keyOrReason] = s.is_baby ? addPendingBaby(state, s.animal_id, true) : addReceptionAnimal(state, s.animal_id); + if (!ok) { + setError(keyOrReason === "NoFreeNursery" ? noFreeNursery : keyOrReason === "NoFreeReception" ? noFreeReception : String(keyOrReason)); + setState(); + return; + } + setState(); + deliverSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); + }); + row.appendChild(deliverBtn); + salesPanel.appendChild(row); + } + } + if (api.active && api.active.length > 0) { + const activeTitle = document.createElement("div"); + activeTitle.className = "sales-panel-title"; + activeTitle.textContent = salesPanelAuctions; + salesPanel.appendChild(activeTitle); + for (const s of api.active) { + if (s.seller_zoo_id === playerZooId) continue; + const row = document.createElement("div"); + row.className = "sales-panel-row sales-panel-row-bid"; + const emoji = animalEmoji[s.animal_id] ?? "🐾"; + const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id); + const minBid = (s.best_bid_amount ?? s.initial_price) + 1; + row.innerHTML = `${emoji}${label}${s.initial_price} 💰`; + const input = document.createElement("input"); + input.type = "number"; + input.min = String(minBid); + input.value = String(minBid); + input.className = "sales-bid-input"; + input.setAttribute("aria-label", salesBidInputAriaLabel); + const bidBtn = document.createElement("button"); + bidBtn.type = "button"; + bidBtn.textContent = salesBtnBid; + bidBtn.className = "sales-btn-bid"; + bidBtn.addEventListener("click", () => { + const amount = Number(input.value) || minBid; + placeBid(s.id, amount).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); }); + }); + row.appendChild(input); + row.appendChild(bidBtn); + salesPanel.appendChild(row); + } + } + } + if (salesPanel.childNodes.length > 0) worldMapEl.appendChild(salesPanel); + + const cellsLayer = document.createElement("div"); + cellsLayer.className = "world-map-cells"; + cellsLayer.setAttribute("aria-hidden", "true"); + cellsLayer.style.gridTemplateColumns = `repeat(${WORLD_MAP_GRID_COLS}, 1fr)`; + cellsLayer.style.gridTemplateRows = `repeat(${WORLD_MAP_GRID_ROWS}, 1fr)`; + for (let row = 0; row < WORLD_MAP_GRID_ROWS; row++) { + for (let col = 0; col < WORLD_MAP_GRID_COLS; col++) { + const cellDiv = document.createElement("div"); + cellDiv.className = "world-map-cell"; + const biome = getCellBiome(WORLD_MAP_GRID_COLS, WORLD_MAP_GRID_ROWS, col + 1, row + 1); + cellDiv.classList.add(`world-map-cell-${biome.toLowerCase()}`); + cellsLayer.appendChild(cellDiv); + } + } + worldMapEl.appendChild(cellsLayer); + const zoos = state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }]; + const offers = state.conveyorOffers || []; + for (const zoo of zoos) { + const isPlayer = zoo.id === "player"; + const zooOffers = offers.filter((o) => (o.zooId ?? "player") === zoo.id); + const oneOffer = !isPlayer && zooOffers.length > 0 ? zooOffers[0] : null; + const playerBabyOffer = isPlayer ? zooOffers.find((o) => o.type === "baby") : null; + const playerAnimalOffer = isPlayer ? zooOffers.find((o) => o.type === "animal") : null; + const node = document.createElement("div"); + node.className = "world-map-zoo" + (isPlayer ? " world-map-zoo-player" : ""); + node.style.left = `${zoo.x}%`; + node.style.top = `${zoo.y}%`; + node.dataset.zooId = zoo.id; + const nameEl = document.createElement("div"); + nameEl.className = "world-map-zoo-name"; + nameEl.textContent = zoo.name; + node.appendChild(nameEl); + if (isPlayer) { + const scoreEl = document.createElement("div"); + scoreEl.className = "world-map-zoo-reproduction-score"; + scoreEl.textContent = `Score repro: ${(state.reproductionScore ?? 0).toFixed(1)}`; + node.appendChild(scoreEl); + const attrEl = document.createElement("div"); + attrEl.className = "world-map-zoo-attractivity-score"; + attrEl.textContent = `Score attractivité: ${(state.attractivityScore ?? 0).toFixed(1)}`; + node.appendChild(attrEl); + } + if (!isPlayer && zoo.botState) { + const indEl = document.createElement("div"); + indEl.className = "world-map-zoo-indicators"; + indEl.textContent = `${Math.floor(zoo.botState.coins)} · Parcelle ${zoo.botState.plotLevel}`; + node.appendChild(indEl); + } + const slotEl = document.createElement("div"); + slotEl.className = "world-map-zoo-slot"; + const zooListings = isPlayer ? zooListingsForPlayer : []; + if (isPlayer && zooListings.length > 0) { + for (const listing of zooListings.slice(0, 3)) { + const el = document.createElement("div"); + el.className = "world-map-sale-listing"; + const emoji = animalEmoji[listing.animalId] ?? "🐾"; + const label = listing.isBaby ? `Bébé ${animalLabel[listing.animalId] ?? listing.animalId}` : (animalLabel[listing.animalId] ?? listing.animalId); + el.innerHTML = `${emoji}${label}${listing.price} 💰`; + el.title = "En vente sur la carte (phase 10)"; + slotEl.appendChild(el); + } + } else if (oneOffer) { + const el = document.createElement("div"); + el.className = "offer-btn world-map-offer world-map-offer-single"; + el.setAttribute("role", "button"); + el.setAttribute("tabindex", "0"); + el.setAttribute("draggable", "true"); + const name = eggTypeLabel[oneOffer.eggType] ?? oneOffer.eggType; + el.innerHTML = `${EGG_EMOJI}${name}${oneOffer.price} pièces`; + let dragStarted = false; + el.addEventListener("dragstart", (e) => { + dragStarted = true; + e.dataTransfer.setData("application/x-builazoo-eggtype", oneOffer.eggType); + e.dataTransfer.setData("application/x-builazoo-offer-zooid", zoo.id); + e.dataTransfer.effectAllowed = "copy"; + el.classList.add("dragging"); + }); + el.addEventListener("dragend", () => { + dragStarted = false; + el.classList.remove("dragging"); + }); + el.addEventListener("click", () => { + if (dragStarted) return; + setError("Glissez l'œuf sur le camion pour l'acheter."); + setState(); + }); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + el.click(); + } + }); + slotEl.appendChild(el); + } else if (isPlayer && (playerBabyOffer || playerAnimalOffer)) { + if (playerBabyOffer) { + const el = document.createElement("div"); + el.className = "offer-btn world-map-offer"; + el.setAttribute("draggable", "true"); + const emoji = animalEmoji[playerBabyOffer.animalId] ?? "🐾"; + const name = animalLabel[playerBabyOffer.animalId] ?? playerBabyOffer.animalId; + el.innerHTML = `${emoji}Bébé ${name}${playerBabyOffer.price}`; + el.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("application/x-builazoo-baby-offer", `${playerBabyOffer.animalId}:${playerBabyOffer.price}`); + e.dataTransfer.effectAllowed = "copy"; + }); + slotEl.appendChild(el); + } + if (playerAnimalOffer) { + const el = document.createElement("div"); + el.className = "offer-btn world-map-offer"; + el.setAttribute("draggable", "true"); + const emoji = animalEmoji[playerAnimalOffer.animalId] ?? "🐾"; + const name = animalLabel[playerAnimalOffer.animalId] ?? playerAnimalOffer.animalId; + el.innerHTML = `${emoji}${name}${playerAnimalOffer.price}`; + el.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("application/x-builazoo-animal-offer", `${playerAnimalOffer.animalId}:${playerAnimalOffer.price}`); + e.dataTransfer.effectAllowed = "copy"; + }); + slotEl.appendChild(el); + } + } else { + const iconEl = document.createElement("span"); + iconEl.className = "world-map-zoo-icon"; + iconEl.setAttribute("aria-hidden", "true"); + iconEl.textContent = "🏠"; + slotEl.appendChild(iconEl); + } + node.appendChild(slotEl); + worldMapEl.appendChild(node); + } + const cities = GameConfig.WorldMap?.Cities ?? []; + for (const city of cities) { + const cityEl = document.createElement("div"); + cityEl.className = "world-map-city"; + cityEl.style.left = `${city.x}%`; + cityEl.style.top = `${city.y}%`; + const maxVisitors = city.maxVisitorsTowardZoos ?? 0; + cityEl.title = maxVisitors > 0 ? `${city.name} — max ${maxVisitors} visiteurs vers zoos` : city.name; + cityEl.setAttribute("aria-label", maxVisitors > 0 ? `${city.name}, ${maxVisitors} visiteurs max vers zoos` : city.name); + const icon = document.createElement("span"); + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "🏙️"; + cityEl.appendChild(icon); + const cityLabel = document.createElement("div"); + cityLabel.className = "world-map-city-label"; + cityLabel.textContent = city.name; + cityEl.appendChild(cityLabel); + if (maxVisitors > 0) { + const cityMax = document.createElement("div"); + cityMax.className = "world-map-city-max-visitors"; + cityMax.textContent = `max ${maxVisitors}`; + cityMax.setAttribute("aria-hidden", "true"); + cityEl.appendChild(cityMax); + } + worldMapEl.appendChild(cityEl); + } + const lab = GameConfig.WorldMap?.Laboratory; + if (lab) { + const labNode = document.createElement("div"); + labNode.className = "world-map-lab"; + labNode.style.left = `${lab.x}%`; + labNode.style.top = `${lab.y}%`; + labNode.dataset.poi = "laboratory"; + const labNameEl = document.createElement("div"); + labNameEl.className = "world-map-zoo-name"; + labNameEl.textContent = lab.name ?? "Laboratoire"; + labNode.appendChild(labNameEl); + const labSlotEl = document.createElement("div"); + labSlotEl.className = "world-map-zoo-slot"; + const labOffer = state.laboratoryOffer; + if (labOffer) { + const el = document.createElement("div"); + el.className = "offer-btn world-map-offer world-map-offer-single world-map-lab-offer"; + el.setAttribute("role", "button"); + el.setAttribute("tabindex", "0"); + el.setAttribute("draggable", "true"); + const name = eggTypeLabel[labOffer.eggType] ?? labOffer.eggType; + el.innerHTML = `${EGG_EMOJI}${name}${labOffer.price} pièces`; + let dragStarted = false; + el.addEventListener("dragstart", (e) => { + dragStarted = true; + e.dataTransfer.setData("application/x-builazoo-eggtype", labOffer.eggType); + e.dataTransfer.effectAllowed = "copy"; + el.classList.add("dragging"); + }); + el.addEventListener("dragend", () => { + dragStarted = false; + el.classList.remove("dragging"); + }); + el.addEventListener("click", () => { + if (dragStarted) return; + const [ok, result] = tryBuyLabEgg(state, labOffer.eggType); + if (!ok) { + const msg = errorMessage[result] ?? result; + setError(String(t.buyFailed).replace("%s", msg)); + playSound("error"); + setState(); + return; + } + setError(""); + playSound("buy"); + pendingTokenByEggType[labOffer.eggType] = result.tokenId; + setState(); + }); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + el.click(); + } + }); + labSlotEl.appendChild(el); + } else { + const iconEl = document.createElement("span"); + iconEl.className = "world-map-zoo-icon"; + iconEl.setAttribute("aria-hidden", "true"); + iconEl.textContent = "🔬"; + labSlotEl.appendChild(iconEl); + } + labNode.appendChild(labSlotEl); + worldMapEl.appendChild(labNode); + } + const truckMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500; + const truckSale = state.truckSale; + const eggPurchase = state.eggPurchaseTruck; + const truckLevel = state.truckLevel ?? 1; + if (truckSale && truckSale.toZooId) { + const elapsed = Date.now() - (truckSale.startAt || 0); + if (elapsed >= truckMs) { + delete state.truckSale; + } else { + const fromZoo = zoos.find((z) => z.id === "player"); + const toZoo = zoos.find((z) => z.id === truckSale.toZooId); + if (fromZoo && toZoo) { + const progress = elapsed / truckMs; + const x = fromZoo.x + (toZoo.x - fromZoo.x) * progress; + const y = fromZoo.y + (toZoo.y - fromZoo.y) * progress; + worldMapTruckEl.style.display = "block"; + worldMapTruckEl.style.left = `${x}%`; + worldMapTruckEl.style.top = `${y}%`; + worldMapTruckEl.textContent = "🚚"; + setTimeout(setState, 50); + } + } + } else if (eggPurchase && eggPurchase.startAt) { + const durationMs = Math.max(1000, (truckMs * 2) / truckLevel); + const elapsed = Date.now() - eggPurchase.startAt; + if (elapsed >= durationMs) { + delete state.eggPurchaseTruck; + worldMapTruckEl.style.display = "none"; + } else { + const fromZoo = zoos.find((z) => z.id === eggPurchase.fromZooId); + const toZoo = zoos.find((z) => z.id === eggPurchase.toZooId); + if (fromZoo && toZoo) { + const progress = elapsed / durationMs; + let x; let y; + if (progress < 0.5) { + const leg = progress * 2; + x = fromZoo.x + (toZoo.x - fromZoo.x) * leg; + y = fromZoo.y + (toZoo.y - fromZoo.y) * leg; + } else { + const leg = (progress - 0.5) * 2; + x = toZoo.x + (fromZoo.x - toZoo.x) * leg; + y = toZoo.y + (fromZoo.y - toZoo.y) * leg; + } + worldMapTruckEl.style.display = "block"; + worldMapTruckEl.style.left = `${x}%`; + worldMapTruckEl.style.top = `${y}%`; + worldMapTruckEl.textContent = "🚚"; + setTimeout(setState, 50); + } + } + } else { + worldMapTruckEl.style.display = "none"; + } + worldMapNpcTrucksEl.innerHTML = ""; + const npcTrucks = state.worldTruckSales ?? []; + for (const t of npcTrucks) { + const fromZoo = zoos.find((z) => z.id === t.fromZooId); + const toZoo = zoos.find((z) => z.id === t.toZooId); + if (fromZoo && toZoo) { + const elapsed = Date.now() - (t.startAt || 0); + if (elapsed < truckMs) { + const progress = elapsed / truckMs; + const x = fromZoo.x + (toZoo.x - fromZoo.x) * progress; + const y = fromZoo.y + (toZoo.y - fromZoo.y) * progress; + const truckDiv = document.createElement("div"); + truckDiv.className = "world-map-truck world-map-truck-npc"; + truckDiv.style.left = `${x}%`; + truckDiv.style.top = `${y}%`; + truckDiv.textContent = "🚚"; + worldMapNpcTrucksEl.appendChild(truckDiv); + } + } + } + if (npcTrucks.length > 0 || (truckSale && truckSale.toZooId) || (eggPurchase && eggPurchase.startAt)) { + setTimeout(setState, 50); + } + } + + function renderGrid() { + gridEl.style.gridTemplateColumns = `repeat(${state.grid.width}, 48px)`; + gridEl.style.gridTemplateRows = `repeat(${state.grid.height}, 48px)`; + gridEl.innerHTML = ""; + for (let y = 1; y <= state.grid.height; y++) { + for (let x = 1; x <= state.grid.width; x++) { + const key = `${x}_${y}`; + const cell = state.grid.cells[key]; + const div = document.createElement("div"); + div.className = "cell"; + const biome = getDisplayBiome(x, y, state.grid); + const temp = getDisplayTemperature(x, y, state.grid); + const tempBand = getTemperatureBand(temp); + div.classList.add(`biome-${biome.toLowerCase()}`, `temp-${tempBand}`); + const hatchedList = getHatched(); + if (hatchedList.some((h) => h.x === x && h.y === y)) div.classList.add("just-hatched"); + div.setAttribute("role", "button"); + div.setAttribute("tabindex", "0"); + div.dataset.x = String(x); + div.dataset.y = String(y); + const isSelected = selected.x === x && selected.y === y; + if (isSelected) div.classList.add("selected"); + if (cell === null || cell === undefined) { + div.classList.add("empty"); + if (emptyCellChoice && emptyCellChoice.x === x && emptyCellChoice.y === y) { + const nurseryCost = getNurseryBuildCost(); + const shopCost = getSouvenirShopBuildCost(); + const researchCost = getResearchBuildCost(); + const billeterieCost = getBilleterieBuildCost(); + const foodCost = getFoodBuildCost(); + const receptionCost = getReceptionBuildCost(); + const biomeColorCost = getBiomeChangeColorBuildCost(); + const biomeTempCost = getBiomeChangeTempBuildCost(); + const canNursery = state.coins >= nurseryCost; + const canShop = state.coins >= shopCost; + const canResearch = state.coins >= researchCost; + const canBilleterie = state.coins >= billeterieCost; + const canFood = state.coins >= foodCost; + const canReception = state.coins >= receptionCost; + const canBiomeColor = state.coins >= biomeColorCost; + const canBiomeTemp = state.coins >= biomeTempCost; + div.innerHTML = ``; + div.classList.add("empty-choice"); + } else { + div.textContent = ""; + } + } else if (cell.kind === "school") { + div.classList.add("school"); + const schoolMaxLevel = (GameConfig.School && GameConfig.School.MaxLevel) || 8; + const canUpgradeSchool = cell.level < schoolMaxLevel && state.coins >= getSchoolUpgradeCost(cell.level); + if (canUpgradeSchool) div.classList.add("can-upgrade"); + const arrow = canUpgradeSchool ? '' : ""; + div.innerHTML = `🏫École ${cell.level}${arrow}`; + } else if (cell.kind === "nursery") { + div.classList.add("nursery"); + const nurseryLevel = cell.level ?? 1; + const nurseryMax = GameConfig.Nursery?.MaxLevel ?? 7; + const canUpgradeNursery = nurseryLevel < nurseryMax && state.coins >= getNurseryUpgradeCost(nurseryLevel); + if (canUpgradeNursery) div.classList.add("can-upgrade"); + const pendingBaby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === key); + const token = cell.tokenId !== null && cell.tokenId !== undefined ? state.pendingEggTokens.find((t) => t.tokenId === cell.tokenId) : null; + if (pendingBaby) { + const nowUnix = Math.floor(Date.now() / 1000); + const isMature = nowUnix >= pendingBaby.readyAt; + const emoji = animalEmoji[pendingBaby.animalId] ?? "🐾"; + const label = isMature ? "Bébé prêt" : "Bébé…"; + div.classList.add("cell-draggable"); + div.draggable = isMature; + div.innerHTML = `${emoji}${label}`; + if (isMature) div.dataset.nurseryCellKey = key; + } else if (token) { + div.classList.add("cell-draggable"); + div.draggable = true; + const label = eggTypeLabel[token.eggType] ?? token.eggType; + div.innerHTML = `${EGG_EMOJI}${label}`; + div.dataset.tokenId = String(cell.tokenId); + } else { + const arrow = canUpgradeNursery ? '' : ""; + div.innerHTML = `🐣Nurserie ${nurseryLevel}${arrow}`; + } + } else if (cell.kind === "souvenirShop") { + div.classList.add("souvenir-shop"); + const shopLevel = cell.level ?? 1; + const shopMax = GameConfig.SouvenirShop?.MaxLevel ?? 7; + const canUpgradeShop = shopLevel < shopMax && state.coins >= getSouvenirShopUpgradeCost(shopLevel); + if (canUpgradeShop) div.classList.add("can-upgrade"); + const arrow = canUpgradeShop ? '' : ""; + div.innerHTML = `🛒Boutique ${shopLevel}${arrow}`; + } else if (cell.kind === "research") { + div.classList.add("research"); + const level = cell.level ?? 1; + const maxLevel = GameConfig.Research?.MaxLevel ?? 7; + const canUpgrade = level < maxLevel && state.coins >= getResearchUpgradeCost(level); + if (canUpgrade) div.classList.add("can-upgrade"); + const arrow = canUpgrade ? '' : ""; + div.innerHTML = `🔬Recherche ${level}${arrow}`; + } else if (cell.kind === "billeterie") { + div.classList.add("billeterie"); + const level = cell.level ?? 1; + const maxLevel = GameConfig.Billeterie?.MaxLevel ?? 7; + const canUpgrade = level < maxLevel && state.coins >= getBilleterieUpgradeCost(level); + if (canUpgrade) div.classList.add("can-upgrade"); + const arrow = canUpgrade ? '' : ""; + div.innerHTML = `🎫Billeterie ${level}${arrow}`; + } else if (cell.kind === "food") { + div.classList.add("food"); + const level = cell.level ?? 1; + const maxLevel = GameConfig.Food?.MaxLevel ?? 7; + const canUpgrade = level < maxLevel && state.coins >= getFoodUpgradeCost(level); + if (canUpgrade) div.classList.add("can-upgrade"); + const arrow = canUpgrade ? '' : ""; + div.innerHTML = `🥗Nourriture ${level}${arrow}`; + } else if (cell.kind === "reception") { + div.classList.add("reception"); + const level = cell.level ?? 1; + const maxLevel = GameConfig.Reception?.MaxLevel ?? 7; + const canUpgrade = level < maxLevel && state.coins >= getReceptionUpgradeCost(level); + if (canUpgrade) div.classList.add("can-upgrade"); + const recAnimal = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === key); + const nowUnix = Math.floor(Date.now() / 1000); + if (recAnimal) { + const isReady = nowUnix >= recAnimal.readyAt; + const emoji = animalEmoji[recAnimal.animalId] ?? "🐾"; + const label = isReady ? "Animal prêt" : "Acclimatation…"; + div.classList.add("cell-draggable"); + div.draggable = isReady; + const arrow = canUpgrade ? '' : ""; + div.innerHTML = `${emoji}${label}${arrow}`; + if (isReady) div.dataset.receptionCellKey = key; + } else { + const arrow = canUpgrade ? '' : ""; + div.innerHTML = `📥Accueil ${level}${arrow}`; + } + } else if (cell.kind === "biomeChangeColor") { + div.classList.add("biome-change-color"); + const level = cell.level ?? 1; + const maxLevel = GameConfig.BiomeChangeColor?.MaxLevel ?? 7; + const canUpgrade = level < maxLevel && state.coins >= getBiomeChangeColorUpgradeCost(level); + if (canUpgrade) div.classList.add("can-upgrade"); + const arrow = canUpgrade ? '' : ""; + div.innerHTML = `🎨Couleur ${level}${arrow}`; + } else if (cell.kind === "biomeChangeTemp") { + div.classList.add("biome-change-temp"); + const level = cell.level ?? 1; + const maxLevel = GameConfig.BiomeChangeTemp?.MaxLevel ?? 7; + const canUpgrade = level < maxLevel && state.coins >= getBiomeChangeTempUpgradeCost(level); + if (canUpgrade) div.classList.add("can-upgrade"); + const arrow = canUpgrade ? '' : ""; + div.innerHTML = `🌡️Temp ${level}${arrow}`; + } else if (cell.kind === "egg") { + div.classList.add("egg", "cell-draggable"); + div.draggable = true; + const label = eggTypeLabel[cell.eggType] ?? cell.eggType; + div.innerHTML = `${EGG_EMOJI}${label}`; + } else { + div.classList.add("animal", "cell-draggable"); + div.draggable = true; + const w = cell.cellsWide ?? 1; + const h = cell.cellsHigh ?? 1; + const isMulti = w > 1 || h > 1; + const isOrigin = cell.originKey === null || cell.originKey === undefined || cell.originKey === key; + if (isMulti) div.classList.add("multi-cell"); + if (isMulti && isOrigin) div.classList.add("multi-cell-origin"); + const emoji = animalEmoji[cell.id] ?? "🐾"; + const label = animalLabel[cell.id] ?? cell.id; + div.innerHTML = `${emoji}${label}`; + } + if (cell !== null && cell !== undefined && (cell.kind === "egg" || cell.kind === "animal" || (cell.kind === "nursery" && (cell.tokenId !== null && cell.tokenId !== undefined || div.dataset.nurseryCellKey)) || (cell.kind === "reception" && div.dataset.receptionCellKey))) { + div.addEventListener("dragstart", (e) => { + let dragX = x; + let dragY = y; + if (div.dataset.nurseryCellKey) { + e.dataTransfer.setData("application/x-builazoo-nursery-cell-key", div.dataset.nurseryCellKey); + e.dataTransfer.effectAllowed = "move"; + } else if (div.dataset.receptionCellKey) { + e.dataTransfer.setData("application/x-builazoo-reception-cell-key", div.dataset.receptionCellKey); + e.dataTransfer.effectAllowed = "move"; + } else if (cell.kind === "animal" && cell.originKey !== null && cell.originKey !== undefined) { + const m = cell.originKey.match(/^(\d+)_(\d+)$/); + if (m) { + dragX = Number(m[1]); + dragY = Number(m[2]); + } + } + if (!div.dataset.nurseryCellKey && !div.dataset.receptionCellKey) { + e.dataTransfer.setData("text/plain", `${dragX}_${dragY}`); + } + if (cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) e.dataTransfer.setData("application/x-builazoo-tokenid", String(cell.tokenId)); + e.dataTransfer.effectAllowed = e.dataTransfer.effectAllowed || "move"; + div.classList.add("dragging"); + const ghost = div.cloneNode(true); + ghost.classList.add("drag-ghost"); + ghost.style.opacity = "1"; + document.body.appendChild(ghost); + e.dataTransfer.setDragImage(ghost, 24, 24); + const cleanup = () => { ghost.remove(); }; + div.addEventListener("dragend", cleanup, { once: true }); + }); + } + if (cell !== null && cell !== undefined && (cell.kind === "egg" || cell.kind === "animal" || (cell.kind === "nursery" && (cell.tokenId !== null && cell.tokenId !== undefined || div.dataset.nurseryCellKey)) || (cell.kind === "reception" && div.dataset.receptionCellKey))) { + div.addEventListener("dragend", () => { + div.classList.remove("dragging"); + gridEl.querySelectorAll(".cell").forEach((c) => c.classList.remove("dragover")); + }); + } + div.addEventListener("dragover", (e) => { + e.preventDefault(); + const hasEggType = e.dataTransfer.types.includes("application/x-builazoo-eggtype"); + const hasTokenId = e.dataTransfer.types.includes("application/x-builazoo-tokenid"); + const hasNurseryKey = e.dataTransfer.types.includes("application/x-builazoo-nursery-cell-key"); + const hasReceptionKey = e.dataTransfer.types.includes("application/x-builazoo-reception-cell-key"); + e.dataTransfer.dropEffect = hasEggType || hasTokenId ? "copy" : "move"; + if (cell === null || cell === undefined && (hasEggType || hasTokenId || hasNurseryKey || hasReceptionKey)) div.classList.add("dragover"); + }); + div.addEventListener("dragleave", () => { + div.classList.remove("dragover"); + }); + div.addEventListener("drop", (e) => { + e.preventDefault(); + div.classList.remove("dragover"); + const toX = Number(div.dataset.x); + const toY = Number(div.dataset.y); + const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key"); + if (nurseryCellKey && cell === null || cell === undefined) { + const nowUnix = Math.floor(Date.now() / 1000); + const [ok, reason] = placeMatureBabyOnCell(state, { nurseryCellKey, toX, toY, nowUnix }); + if (ok) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + lastActionWasDrop = true; + setState(); + return; + } + const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key"); + if (receptionCellKey && cell === null || cell === undefined) { + const nowUnix = Math.floor(Date.now() / 1000); + const [ok, reason] = placeReceptionAnimalOnCell(state, { receptionCellKey, toX, toY, nowUnix }); + if (ok) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + lastActionWasDrop = true; + setState(); + return; + } + const tokenIdStr = e.dataTransfer.getData("application/x-builazoo-tokenid"); + if (tokenIdStr && cell === null || cell === undefined) { + const tokenId = Number(tokenIdStr); + if (!Number.isNaN(tokenId)) { + const nowUnix = Math.floor(Date.now() / 1000); + const [placeOk, placeReason] = tryPlaceEgg(state, { tokenId, x: toX, y: toY, nowUnix }); + if (placeOk) { + setError(""); + playSound("place"); + } else { + setError(String(t.errorPrefix).replace("%s", errorMessage[placeReason] ?? placeReason)); + playSound("error"); + } + lastActionWasDrop = true; + setState(); + return; + } + } + const eggTypeFromConveyor = e.dataTransfer.getData("application/x-builazoo-eggtype"); + if (eggTypeFromConveyor && cell === null || cell === undefined) { + const [buyOk, buyResult] = tryBuyEgg(state, eggTypeFromConveyor); + if (!buyOk) { + setError(String(t.buyFailed).replace("%s", errorMessage[buyResult] ?? buyResult)); + playSound("error"); + } else { + const tokenId = buyResult.tokenId; + const nowUnix = Math.floor(Date.now() / 1000); + const [placeOk, placeReason] = tryPlaceEgg(state, { tokenId, x: toX, y: toY, nowUnix }); + if (placeOk) { + setError(""); + playSound("place"); + } else { + setError(String(t.errorPrefix).replace("%s", errorMessage[placeReason] ?? placeReason)); + playSound("error"); + } + } + lastActionWasDrop = true; + setState(); + return; + } + const raw = e.dataTransfer.getData("text/plain"); + if (!raw || !/^\d+_\d+$/.test(raw)) return; + const [sx, sy] = raw.split("_").map(Number); + const [ok, reason] = moveCell(state, { fromX: sx, fromY: sy, toX, toY }); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + lastActionWasDrop = true; + setState(); + }); + div.addEventListener("click", (e) => { + if (lastActionWasDrop) { + lastActionWasDrop = false; + return; + } + const choiceBtn = e.target.closest(".cell-choice-btn"); + if (choiceBtn && cell === null || cell === undefined && emptyCellChoice && emptyCellChoice.x === x && emptyCellChoice.y === y) { + const choice = choiceBtn.dataset.choice; + if (choice === "nursery") { + const [ok, reason] = tryBuildNursery(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } else if (choice === "shop") { + const [ok, reason] = tryBuildSouvenirShop(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } else if (choice === "research") { + const [ok, reason] = tryBuildResearch(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } else if (choice === "billeterie") { + const [ok, reason] = tryBuildBilleterie(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } else if (choice === "food") { + const [ok, reason] = tryBuildFood(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } else if (choice === "reception") { + const [ok, reason] = tryBuildReception(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } else if (choice === "biomeColor") { + const [ok, reason] = tryBuildBiomeChangeColor(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } else if (choice === "biomeTemp") { + const [ok, reason] = tryBuildBiomeChangeTemp(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + emptyCellChoice = null; + } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) { + selectedTokenId = cell.tokenId; + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined)) { + const hasBaby = (state.pendingBabies ?? []).some((p) => p.nurseryCellKey === key); + if (!hasBaby) { + const [ok, reason] = tryUpgradeNursery(state, x, y); + if (ok) { + setError(""); + playSound("upgrade"); + } else if (reason !== "NurseryMaxLevel") { + setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } + } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") { + const [ok, reason] = tryUpgradeSouvenirShop(state, x, y); + if (ok) { + setError(""); + playSound("upgrade"); + } else if (reason !== "SouvenirShopMaxLevel") { + setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "research") { + const [ok, reason] = tryUpgradeResearch(state, x, y); + if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "ResearchMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "billeterie") { + const [ok, reason] = tryUpgradeBilleterie(state, x, y); + if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BilleterieMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "food") { + const [ok, reason] = tryUpgradeFood(state, x, y); + if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "FoodMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "reception") { + const hasAnimal = (state.receptionAnimals ?? []).some((r) => r.receptionCellKey === key); + if (!hasAnimal) { + const [ok, reason] = tryUpgradeReception(state, x, y); + if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "ReceptionMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "biomeChangeColor") { + const [ok, reason] = tryUpgradeBiomeChangeColor(state, x, y); + if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BiomeChangeColorMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "biomeChangeTemp") { + const [ok, reason] = tryUpgradeBiomeChangeTemp(state, x, y); + if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BiomeChangeTempMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "school") { + const [ok, reason] = tryUpgradeSchool(state, x, y); + if (!ok) { + setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } else { + setError(""); + playSound("schoolUpgrade"); + } + setState(); + return; + } + selected.x = x; + selected.y = y; + clampSelection(); + if (cell === null || cell === undefined && emptyCellChoice && emptyCellChoice.x === x && emptyCellChoice.y === y) { + emptyCellChoice = null; + setState(); + return; + } + const nurseryKeys = getNurseryCellKeysOrdered(state); + let firstTokenId = null; + for (const k of nurseryKeys) { + const c = state.grid.cells[k]; + if (c && c.kind === "nursery" && c.tokenId !== null && c.tokenId !== undefined) { + firstTokenId = c.tokenId; + break; + } + } + const tokenId = selectedTokenId ?? firstTokenId; + if (cell === null || cell === undefined && tokenId !== null && tokenId !== undefined) { + const nowUnix = Math.floor(Date.now() / 1000); + const [ok, reason] = tryPlaceEgg(state, { tokenId, x, y, nowUnix }); + if (ok) { + selectedTokenId = null; + setError(""); + playSound("place"); + } else { + setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } + setState(); + return; + } + if (cell === null || cell === undefined) { + emptyCellChoice = { x, y }; + } + setState(); + }); + div.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + div.click(); + } + }); + gridEl.appendChild(div); + } + } + } + + prestigeBtn.addEventListener("click", () => { + if (!canPrestige(state)) return; + doPrestige(state); + refreshOffers(state, Math.floor(Date.now() / 1000)); + setError(""); + playSound("upgrade"); + setState(); + }); + + const fullRender = () => { + clampSelection(); + updateStatus(); + const canUpTruck = (state.truckLevel ?? 1) < ((GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5) + && state.coins >= getTruckUpgradeCost(state.truckLevel ?? 1); + sellZone.classList.toggle("can-upgrade", canUpTruck); + const truckArrow = sellZone.querySelector(".sell-zone-upgrade-arrow"); + if (truckArrow) truckArrow.style.display = canUpTruck ? "" : "none"; + const plotMaxLevel = GameConfig.Plot.MaxLevel || 8; + const canUpgradePlot = (state.plotLevel ?? 1) < plotMaxLevel && state.coins >= getPlotUpgradeCost(state.plotLevel ?? 1); + plotUpgradeZone.classList.toggle("can-upgrade", canUpgradePlot); + const plotArrow = plotUpgradeZone.querySelector(".plot-upgrade-zone-arrow"); + if (plotArrow) plotArrow.style.display = canUpgradePlot ? "" : "none"; + const mapCfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade; + const mapMaxLevel = mapCfg ? mapCfg.MaxLevel : 5; + const mapLevel = state.worldMapLevel ?? 1; + const mapResearchCost = getWorldMapUpgradeResearchCost(mapLevel); + const canUpgradeMap = mapLevel < mapMaxLevel && (state.researchPoints ?? 0) >= mapResearchCost; + worldMapUpgradeZone.classList.toggle("can-upgrade", canUpgradeMap); + worldMapUpgradeZone.title = mapLevel < mapMaxLevel + ? `Agrandir la carte (${mapResearchCost} unités de recherche)` + : "Agrandir la carte"; + const mapCostEl = worldMapUpgradeZone.querySelector(".world-map-upgrade-zone-cost"); + if (mapCostEl) mapCostEl.textContent = mapLevel < mapMaxLevel ? ` ${mapResearchCost} 🔬` : ""; + const mapArrow = worldMapUpgradeZone.querySelector(".world-map-upgrade-zone-arrow"); + if (mapArrow) mapArrow.style.display = canUpgradeMap ? "" : "none"; + const babiesForSale = (state.saleListings ?? []).filter((s) => s.isBaby).length; + const animalsForSale = (state.saleListings ?? []).filter((s) => !s.isBaby).length; + const labsCount = GameConfig.WorldMap && GameConfig.WorldMap.Laboratory ? 1 : 0; + const zoosCount = (state.worldZoos ?? []).length; + const citiesCount = (GameConfig.WorldMap && GameConfig.WorldMap.Cities) ? GameConfig.WorldMap.Cities.length : 0; + worldMapCounters.textContent = ""; + const counterEntries = [ + ["Bébés à vendre", babiesForSale], + ["Animaux à vendre", animalsForSale], + ["Laboratoires", labsCount], + ["Zoos", zoosCount], + ["Villes", citiesCount], + ]; + for (const [label, value] of counterEntries) { + const span = document.createElement("span"); + span.className = "world-map-counter"; + span.title = label; + span.setAttribute("aria-label", `${label}: ${value}`); + span.textContent = `${label}: ${value}`; + worldMapCounters.appendChild(span); + } + const eggPurchase = state.eggPurchaseTruck; + if (eggPurchase && eggPurchase.startAt) { + const truckLevel = state.truckLevel ?? 1; + const baseMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500; + const durationMs = Math.max(1000, (baseMs * 2) / truckLevel); + if (Date.now() - eggPurchase.startAt >= durationMs) delete state.eggPurchaseTruck; + } + renderWorldMap(); + renderGrid(); + }; + + fullRender(); + return fullRender; +} diff --git a/web/js/visitor-attraction.js b/web/js/visitor-attraction.js new file mode 100644 index 0000000..ca0d3c9 --- /dev/null +++ b/web/js/visitor-attraction.js @@ -0,0 +1,98 @@ +import { LootTables } from "./loot-tables.js"; +import { getSellValue } from "./economy.js"; +import { getIncomeMultiplier } from "./mutation-rules.js"; + +const CELL_PITCH = 52; +const GRID_PADDING = 6; +const CELL_CENTER_OFFSET = 24; + +/** + * Center of a cell in layer pixel coordinates (1-based x, y). + * @param {number} x 1-based column + * @param {number} y 1-based row + * @returns {{ cx: number, cy: number }} + */ +function cellCenterPx(x, y) { + const cx = GRID_PADDING + (x - 1) * CELL_PITCH + CELL_CENTER_OFFSET; + const cy = GRID_PADDING + (y - 1) * CELL_PITCH + CELL_CENTER_OFFSET; + return { cx, cy }; +} + +/** + * Weighted center of the zoo by animal value (sell value). Visitors are attracted to expensive animals. + * @param {import("./types.js").GameState} state + * @param {number} gridWidth + * @param {number} gridHeight + * @returns {{ centerX: number, centerY: number }} + */ +export function getAttractionCenter(state, gridWidth, gridHeight) { + let sumW = 0; + let sumWx = 0; + let sumWy = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell.kind === "animal") { + const def = LootTables.Animals[cell.id]; + if (def !== null && def !== undefined) { + const mut = getIncomeMultiplier(cell.mutation ?? "none"); + const w = getSellValue(def.baseIncomePerSecond, cell.level ?? 1, mut, def.sellFactor); + const [x, y] = key.split("_").map(Number); + const { cx, cy } = cellCenterPx(x, y); + sumW += w; + sumWx += w * cx; + sumWy += w * cy; + } + } + } + if (sumW <= 0) { + const cx = GRID_PADDING + (gridWidth * CELL_PITCH - 4) / 2; + const cy = GRID_PADDING + (gridHeight * CELL_PITCH - 4) / 2; + return { centerX: cx, centerY: cy }; + } + return { + centerX: sumWx / sumW, + centerY: sumWy / sumW, + }; +} + +/** + * Cell key (1-based x, y) at the given pixel position in the grid layer. + * @param {number} px + * @param {number} py + * @param {number} gridWidth + * @param {number} gridHeight + * @returns {string} key "x_y" or empty if out of bounds + */ +export function getCellKeyFromPixelPosition(px, py, gridWidth, gridHeight) { + const x = 1 + Math.round((px - GRID_PADDING - CELL_CENTER_OFFSET) / CELL_PITCH); + const y = 1 + Math.round((py - GRID_PADDING - CELL_CENTER_OFFSET) / CELL_PITCH); + if (x < 1 || x > gridWidth || y < 1 || y > gridHeight) return ""; + return `${x}_${y}`; +} + +/** + * Unique position for visitor i: orbits around the attraction center with per-visitor phase, radius and speed. + * A second harmonic gives figure-8 / Lissajous-style paths so each visitor has a distinct walk. + * @param {{ i: number, n: number, t: number, centerX: number, centerY: number, gridWidth: number, gridHeight: number }} opts + * @returns {{ px: number, py: number }} + */ +export function getVisitorPosition(opts) { + const { i, n, t, centerX, centerY, gridWidth, gridHeight } = opts; + const phase1 = (i / Math.max(1, n)) * Math.PI * 2 + ((i * 17) % 100) * 0.01; + const phase2 = ((i * 13) % 100) * 0.063; + const radius1 = 28 + (i * 31) % 55; + const radius2 = 12 + (i * 11) % 18; + const speed1 = 0.15 + ((i * 7) % 50) * 0.008; + const speed2 = 0.08 + ((i * 19) % 40) * 0.006; + const angle1 = phase1 + t * speed1; + const angle2 = phase2 + t * speed2; + const px = centerX + radius1 * Math.cos(angle1) + radius2 * Math.cos(angle2 * 1.3); + const py = centerY + radius1 * Math.sin(angle1) + radius2 * Math.sin(angle2 * 0.9); + const minX = GRID_PADDING; + const minY = GRID_PADDING; + const maxX = GRID_PADDING + gridWidth * CELL_PITCH - 4 - 20; + const maxY = GRID_PADDING + gridHeight * CELL_PITCH - 4 - 20; + return { + px: Math.max(minX, Math.min(maxX, px)), + py: Math.max(minY, Math.min(maxY, py)), + }; +} diff --git a/web/js/visitor-incidents.js b/web/js/visitor-incidents.js new file mode 100644 index 0000000..0ec1f26 --- /dev/null +++ b/web/js/visitor-incidents.js @@ -0,0 +1,86 @@ +/** + * Visitor incidents (thirst, bin full, bench required, animal too far, want photo). + * Appear more often during wait phases; resolve by click for bonus, or timeout applies penalty. + */ + +import { GameConfig } from "./config.js"; + +/** Incident type keys for i18n and display. */ +export const INCIDENT_TYPES = ["thirst", "bin", "bench", "animalFar", "photo"]; + +/** Emoji per incident type for bubble display. */ +export const INCIDENT_EMOJI = { thirst: "💧", bin: "🗑️", bench: "🪑", animalFar: "🦌", photo: "📷" }; + +/** + * True when player is in a wait phase (truck moving, sale pending validation, etc.). + * @param {import("./types.js").GameState} state + * @returns {boolean} + */ +export function isInWaitPhase(state) { + if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true; + if (state.truckSale && state.truckSale.startAt) return true; + const api = state.salesFromApi; + if (api && api.asBuyerUndelivered && api.asBuyerUndelivered.length > 0) { + const nowMs = Date.now(); + for (const s of api.asBuyerUndelivered) { + const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0; + const pending = (s.status === "sold" || s.status === "validated") && validatedAtMs > nowMs; + if (pending) return true; + } + } + return false; +} + +/** + * Spawn and expire incidents. Call after tickVisitorArrivals. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function tickVisitorIncidents(state, nowUnix) { + const arrivals = state.visitorArrivals ?? []; + const cfg = GameConfig.Visitor; + const baseChance = cfg?.IncidentChanceBase ?? 0.002; + const waitMult = cfg?.IncidentChanceWaitMultiplier ?? 4; + const timeoutSec = cfg?.IncidentTimeoutSeconds ?? 45; + const penalty = cfg?.IncidentUnresolvedAttractivityPenalty ?? 0.2; + const inWait = isInWaitPhase(state); + const chance = inWait ? baseChance * waitMult : baseChance; + const toRemove = []; + + for (let i = 0; i < arrivals.length; i++) { + const v = arrivals[i]; + if (v.incidentType !== null && v.incidentType !== undefined) { + if (nowUnix - (v.incidentSince ?? nowUnix) >= timeoutSec) { + state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) - penalty; + toRemove.push(i); + } + } else if (Math.random() < chance) { + v.incidentType = INCIDENT_TYPES[Math.floor(Math.random() * INCIDENT_TYPES.length)]; + v.incidentSince = nowUnix; + } + } + for (let r = toRemove.length - 1; r >= 0; r--) { + arrivals.splice(toRemove[r], 1); + } +} + +/** + * Resolve incident for visitor at index: clear incident, add coins and attractivity bonus. Mutates state. + * @param {import("./types.js").GameState} state + * @param {number} visitorIndex + * @returns {boolean} true if an incident was resolved + */ +export function resolveIncident(state, visitorIndex) { + const arrivals = state.visitorArrivals ?? []; + const v = arrivals[visitorIndex]; + if (!v || (v.incidentType === null || v.incidentType === undefined)) return false; + const cfg = GameConfig.Visitor; + const coinBonus = cfg?.IncidentResolveCoinBonus ?? 8; + const attractivityBonus = cfg?.IncidentResolveAttractivityBonus ?? 0.15; + state.coins += coinBonus; + state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) + attractivityBonus; + if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + coinBonus; + delete v.incidentType; + delete v.incidentSince; + return true; +} diff --git a/web/js/weighted-random.js b/web/js/weighted-random.js new file mode 100644 index 0000000..a672bf6 --- /dev/null +++ b/web/js/weighted-random.js @@ -0,0 +1,33 @@ +/** + * Seeded RNG (mulberry32) for deterministic hatch results. + * @param {number} seed + * @returns {() => number} + */ +export function createSeededRng(seed) { + let state = seed >>> 0; + return function next() { + state = (state + 0x6d2b79f5) >>> 0; // 32-bit + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * @param {() => number} rng + * @param {Array<{ id: string, weight: number }>} entries + * @returns {string} + */ +export function pickId(rng, entries) { + let total = 0; + for (const e of entries) total += e.weight; + if (total <= 0) throw new Error("WeightedRandom: non-positive total weight"); + const roll = rng() * total; + let cumulative = 0; + for (const e of entries) { + cumulative += e.weight; + if (roll < cumulative) return e.id; + } + return entries[entries.length - 1].id; +} diff --git a/web/js/world-map.js b/web/js/world-map.js new file mode 100644 index 0000000..0c33d35 --- /dev/null +++ b/web/js/world-map.js @@ -0,0 +1,89 @@ +import { GameConfig } from "./config.js"; +import { LootTables, getColorNames } from "./loot-tables.js"; + +const truckMs = () => (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500; +const npcIntervalMs = () => (GameConfig.WorldMap && GameConfig.WorldMap.NpcTruckIntervalMs) || 8000; + +/** + * Remove expired truck sales (player and NPC). + * @param {import("./types.js").GameState} state + * @param {number} now + */ +export function pruneTruckSales(state, now) { + const ms = truckMs(); + if (state.truckSale && state.truckSale.startAt && now - state.truckSale.startAt >= ms) { + delete state.truckSale; + } + if (state.worldTruckSales && state.worldTruckSales.length > 0) { + state.worldTruckSales = state.worldTruckSales.filter( + (t) => t.startAt && now - t.startAt < ms + ); + } +} + +/** + * Add one NPC truck sale between two random zoos (can include player as destination). + * @param {import("./types.js").GameState} state + * @param {number} now + */ +export function addNpcTruckSale(state, now) { + const zoos = state.worldZoos ?? []; + if (zoos.length < 2) return; + const fromIdx = Math.floor(Math.random() * zoos.length); + let toIdx = Math.floor(Math.random() * zoos.length); + if (toIdx === fromIdx) toIdx = (toIdx + 1) % zoos.length; + const fromZooId = zoos[fromIdx].id; + const toZooId = zoos[toIdx].id; + if (!state.worldTruckSales) state.worldTruckSales = []; + state.worldTruckSales.push({ fromZooId, toZooId, startAt: now }); +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +export function tickWorldMap(state, nowUnix) { + const nowMs = nowUnix * 1000; + pruneTruckSales(state, nowMs); +} + +/** + * Should we add an NPC truck this tick? Call from game loop with a lastNpcTruckAt tracker. + * @param {number} nowMs + * @param {number} lastNpcTruckAt + * @returns {boolean} + */ +export function shouldAddNpcTruck(nowMs, lastNpcTruckAt) { + return nowMs - lastNpcTruckAt >= npcIntervalMs(); +} + +/** + * Laboratory offer duration in seconds. + */ +const LAB_OFFER_DURATION = 45; + +/** + * Chance per refresh (0-1) that the lab has a special offer. + */ +const LAB_OFFER_CHANCE = 0.25; + +/** + * Refresh or expire laboratory offer. Special egg = one of Basic, Ocean, Mountain with a price modifier. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @param {() => number} rng + */ +export function tickLaboratory(state, nowUnix, rng = Math.random) { + const lab = state.laboratoryOffer; + if (lab && nowUnix >= lab.endAt) { + state.laboratoryOffer = null; + } + if (!lab && rng() < LAB_OFFER_CHANCE) { + const types = getColorNames(); + const eggType = types[Math.floor(rng() * types.length)]; + const eggDef = LootTables.EggTypes[eggType]; + const base = eggDef ? eggDef.price : 50; + const price = Math.floor(base * (0.8 + rng() * 0.4)); + state.laboratoryOffer = { eggType, price, endAt: nowUnix + LAB_OFFER_DURATION }; + } +} diff --git a/web/js/zoo-nursery.js b/web/js/zoo-nursery.js new file mode 100644 index 0000000..940d353 --- /dev/null +++ b/web/js/zoo-nursery.js @@ -0,0 +1,36 @@ +/** + * Nursery helpers: ordered keys and first free cell. + */ + +/** + * Ordered nursery cell keys (by row then column) for consistent token assignment. + * @param {import("./types.js").GameState} state + * @returns {string[]} + */ +export function getNurseryCellKeysOrdered(state) { + const keys = []; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell && cell.kind === "nursery") keys.push(key); + } + keys.sort((a, b) => { + const [ax, ay] = a.split("_").map(Number); + const [bx, by] = b.split("_").map(Number); + return ay !== by ? ay - by : ax - bx; + }); + return keys; +} + +/** + * First nursery cell key that has no token and no pending baby. Returns null if none. + * @param {import("./types.js").GameState} state + * @returns {string | null} + */ +export function getFreeNurseryCellKey(state) { + const keys = getNurseryCellKeysOrdered(state); + const usedKeys = new Set((state.pendingBabies ?? []).map((p) => p.nurseryCellKey)); + for (const key of keys) { + const cell = state.grid.cells[key]; + if (cell && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined) && !usedKeys.has(key)) return key; + } + return null; +} diff --git a/web/js/zoo.js b/web/js/zoo.js new file mode 100644 index 0000000..e0340ae --- /dev/null +++ b/web/js/zoo.js @@ -0,0 +1,359 @@ +import { GameConfig } from "./config.js"; +import { LootTables, getRarityHatchMultiplierForEggType } from "./loot-tables.js"; +import { plotSizeFromLevel } from "./grid-utils.js"; +import { getPlotUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js"; +import { findOffer } from "./conveyor.js"; +import { placeEgg, fillAnimalBlock, canPlaceMultiCell } from "./placement.js"; +import { getNurseryCellKeysOrdered, getFreeNurseryCellKey } from "./zoo-nursery.js"; + +export { getNurseryCellKeysOrdered, getFreeNurseryCellKey }; +/** + * First reception cell key that has no reception animal. Returns null if none. + * @param {import("./types.js").GameState} state + * @returns {string | null} + */ +export function getFreeReceptionCellKey(state) { + const usedKeys = new Set((state.receptionAnimals ?? []).map((r) => r.receptionCellKey)); + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell && cell.kind === "reception" && !usedKeys.has(key)) return key; + } + return null; +} + +/** + * Growth duration in seconds for a baby in a nursery of the given level. + * @param {number} nurseryLevel + * @returns {number} + */ +export function getBabyGrowthSeconds(nurseryLevel) { + const base = GameConfig.Nursery?.GrowthSecondsBase ?? 40; + const level = Math.max(1, nurseryLevel ?? 1); + return Math.max(5, Math.floor(base / level)); +} + +/** + * Acclimatation duration in seconds for reception of the given level. + * @param {number} receptionLevel + * @returns {number} + */ +export function getAcclimatationSeconds(receptionLevel) { + const base = GameConfig.Reception?.AcclimatationSecondsBase ?? 45; + const level = Math.max(1, receptionLevel ?? 1); + return Math.max(10, Math.floor(base / level)); +} + +/** + * Add a baby to the first free nursery slot. Mutates state.pendingBabies and state.nextTokenId. + * @param {import("./types.js").GameState} state + * @param {string} animalId + * @param {boolean} [fromOtherZoo] true if baby was bought from another zoo (conveyor/world), false if bred + * @returns {[boolean, string?]} [ok, nurseryCellKey or reason] + */ +export function addPendingBaby(state, animalId, fromOtherZoo) { + const key = getFreeNurseryCellKey(state); + if (key === null || key === undefined) return [false, "NoFreeNursery"]; + if (LootTables.Animals[animalId] === null || LootTables.Animals[animalId] === undefined) return [false, "UnknownAnimal"]; + const cell = state.grid.cells[key]; + const level = cell && cell.kind === "nursery" ? (cell.level ?? 1) : 1; + const now = Math.floor(Date.now() / 1000); + const readyAt = now + getBabyGrowthSeconds(level); + const id = `baby_${state.nextTokenId}`; + state.nextTokenId += 1; + state.pendingBabies = state.pendingBabies ?? []; + state.pendingBabies.push({ id, animalId, nurseryCellKey: key, readyAt, fromOtherZoo: fromOtherZoo === true }); + state.lastEvolutionAt = now; + return [true, key]; +} + +/** + * Add an animal to the first free reception slot. + * @param {import("./types.js").GameState} state + * @param {string} animalId + * @returns {[boolean, string?]} [ok, receptionCellKey or reason] + */ +export function addReceptionAnimal(state, animalId) { + const key = getFreeReceptionCellKey(state); + if (key === null || key === undefined) return [false, "NoFreeReception"]; + if (LootTables.Animals[animalId] === null || LootTables.Animals[animalId] === undefined) return [false, "UnknownAnimal"]; + const cell = state.grid.cells[key]; + const level = cell && cell.kind === "reception" ? (cell.level ?? 1) : 1; + const now = Math.floor(Date.now() / 1000); + const readyAt = now + getAcclimatationSeconds(level); + const id = `reception_${state.nextTokenId}`; + state.nextTokenId += 1; + state.receptionAnimals = state.receptionAnimals ?? []; + state.receptionAnimals.push({ id, animalId, receptionCellKey: key, readyAt }); + state.lastEvolutionAt = now; + return [true, key]; +} + +/** + * Buy a baby offer: pay price and add to nursery if slot free. + * @param {import("./types.js").GameState} state + * @param {string} animalId + * @param {number} price + * @returns {[boolean, string | { nurseryCellKey: string }]} + */ +export function tryBuyBaby(state, animalId, price) { + if (state.coins < price) return [false, "NotEnoughCoins"]; + const [ok, result] = addPendingBaby(state, animalId, true); + if (!ok) return [false, result]; + state.coins -= price; + return [true, { nurseryCellKey: result }]; +} + +/** + * Buy an animal offer: pay price and add to reception if slot free. + * @param {import("./types.js").GameState} state + * @param {string} animalId + * @param {number} price + * @returns {[boolean, string | { receptionCellKey: string }]} + */ +export function tryBuyAnimal(state, animalId, price) { + if (state.coins < price) return [false, "NotEnoughCoins"]; + const [ok, result] = addReceptionAnimal(state, animalId); + if (!ok) return [false, result]; + state.coins -= price; + return [true, { receptionCellKey: result }]; +} + +/** + * Place a mature baby on an empty cell. Baby must be at nurseryCellKey and readyAt <= now. + * @param {import("./types.js").GameState} state + * @param {{ nurseryCellKey: string, toX: number, toY: number, nowUnix: number }} opts + * @returns {[boolean, string?]} + */ +export function placeMatureBabyOnCell(state, opts) { + const { nurseryCellKey, toX, toY, nowUnix } = opts; + const baby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === nurseryCellKey); + if (baby === null || baby === undefined) return [false, "NoBaby"]; + if (nowUnix < baby.readyAt) return [false, "BabyNotReady"]; + const def = LootTables.Animals[baby.animalId]; + if (def === null || def === undefined) return [false, "UnknownAnimal"]; + const w = def.cellsWide ?? 1; + const h = def.cellsHigh ?? 1; + const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h }); + if (!ok) return [false, reason]; + const animalData = { + kind: "animal", + id: baby.animalId, + mutation: "none", + level: 1, + placedAt: nowUnix, + lastVisitedAt: nowUnix, + lastFedAt: nowUnix, + cellsWide: w, + cellsHigh: h, + fromOtherZoo: baby.fromOtherZoo === true, + }; + fillAnimalBlock(state, toX, toY, animalData); + state.pendingBabies = (state.pendingBabies ?? []).filter((p) => p.nurseryCellKey !== nurseryCellKey); + state.lastEvolutionAt = nowUnix; + return [true, undefined]; +} + +/** + * Place a ready reception animal on an empty cell. + * @param {import("./types.js").GameState} state + * @param {{ receptionCellKey: string, toX: number, toY: number, nowUnix: number }} opts + * @returns {[boolean, string?]} + */ +export function placeReceptionAnimalOnCell(state, opts) { + const { receptionCellKey, toX, toY, nowUnix } = opts; + const rec = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === receptionCellKey); + if (rec === null || rec === undefined) return [false, "NoReceptionAnimal"]; + if (nowUnix < rec.readyAt) return [false, "AnimalNotReady"]; + const def = LootTables.Animals[rec.animalId]; + if (def === null || def === undefined) return [false, "UnknownAnimal"]; + const w = def.cellsWide ?? 1; + const h = def.cellsHigh ?? 1; + const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h }); + if (!ok) return [false, reason]; + const animalData = { + kind: "animal", + id: rec.animalId, + mutation: "none", + level: 1, + placedAt: nowUnix, + lastVisitedAt: nowUnix, + lastFedAt: nowUnix, + cellsWide: w, + cellsHigh: h, + fromOtherZoo: true, + }; + fillAnimalBlock(state, toX, toY, animalData); + state.receptionAnimals = (state.receptionAnimals ?? []).filter((r) => r.receptionCellKey !== receptionCellKey); + state.lastEvolutionAt = nowUnix; + return [true, undefined]; +} + +/** + * Assign a token to the first nursery cell that has no tokenId. Mutates state.grid.cells. + * @param {import("./types.js").GameState} state + * @param {number} tokenId + * @returns {boolean} true if assigned + */ +export function assignTokenToNursery(state, tokenId) { + const keys = getNurseryCellKeysOrdered(state); + for (const key of keys) { + const cell = state.grid.cells[key]; + if (cell && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined)) { + cell.tokenId = tokenId; + return true; + } + } + return false; +} + +/** + * Clear the nursery cell that holds this tokenId. + * @param {import("./types.js").GameState} state + * @param {number} tokenId + */ +export function clearNurseryToken(state, tokenId) { + for (const cell of Object.values(state.grid.cells)) { + if (cell && cell.kind === "nursery" && cell.tokenId === tokenId) { + cell.tokenId = undefined; + return; + } + } +} + +/** + * Nursery level of the cell that holds this token (for hatch duration). Returns 1 if not found. + * @param {import("./types.js").GameState} state + * @param {number} tokenId + * @returns {number} + */ +export function getNurseryLevelForToken(state, tokenId) { + for (const cell of Object.values(state.grid.cells)) { + if (cell && cell.kind === "nursery" && cell.tokenId === tokenId) { + return cell.level ?? 1; + } + } + return 1; +} + +/** + * Hatch duration in seconds when placing from a nursery (rarity slows down, nursery level speeds up). + * @param {string} eggType + * @param {number} nurseryLevel + * @returns {number} + */ +export function getHatchDurationSeconds(eggType, nurseryLevel) { + const eggDef = LootTables.EggTypes[eggType]; + if (eggDef === null || eggDef === undefined) return 30; + const base = eggDef.hatchSeconds; + const rarityMult = getRarityHatchMultiplierForEggType(eggType); + const level = Math.max(1, nurseryLevel ?? 1); + return Math.max(5, Math.floor((base * rarityMult) / level)); +} + +function consumeToken(state, tokenId) { + const idx = state.pendingEggTokens.findIndex((t) => t.tokenId === tokenId); + if (idx < 0) return null; + const [token] = state.pendingEggTokens.splice(idx, 1); + return token; +} + +/** + * @param {import("./types.js").GameState} state + * @param {string} eggType + * @returns {[boolean, { tokenId: number, eggType: string } | string]} + */ +export function tryBuyEgg(state, eggType) { + const offer = findOffer(state, eggType); + if (offer === null || offer === undefined) return [false, "OfferUnavailable"]; + const auctionBonus = Math.floor(Math.random() * (offer.price * 0.21)); + const finalPrice = offer.price + auctionBonus; + if (state.coins < finalPrice) return [false, "NotEnoughCoins"]; + if (LootTables.EggTypes[eggType] === null || LootTables.EggTypes[eggType] === undefined) return [false, "UnknownEgg"]; + state.coins -= finalPrice; + const token = { tokenId: state.nextTokenId, eggType, boughtAt: Math.floor(Date.now() / 1000) }; + state.nextTokenId += 1; + state.pendingEggTokens.push(token); + assignTokenToNursery(state, token.tokenId); + return [true, { tokenId: token.tokenId, eggType: token.eggType }]; +} + +/** + * Buy egg from laboratory offer (fixed price, no auction). + * @param {import("./types.js").GameState} state + * @param {string} eggType + * @returns {[boolean, { tokenId: number, eggType: string } | string]} + */ +export function tryBuyLabEgg(state, eggType) { + const offer = state.laboratoryOffer; + if (offer === null || offer === undefined || offer.eggType !== eggType) return [false, "OfferUnavailable"]; + if (state.coins < offer.price) return [false, "NotEnoughCoins"]; + if (LootTables.EggTypes[eggType] === null || LootTables.EggTypes[eggType] === undefined) return [false, "UnknownEgg"]; + state.coins -= offer.price; + state.laboratoryOffer = null; + const token = { tokenId: state.nextTokenId, eggType, boughtAt: Math.floor(Date.now() / 1000) }; + state.nextTokenId += 1; + state.pendingEggTokens.push(token); + assignTokenToNursery(state, token.tokenId); + return [true, { tokenId: token.tokenId, eggType: token.eggType }]; +} + +/** + * @param {import("./types.js").GameState} state + * @param {{ tokenId: number, x: number, y: number, nowUnix: number }} opts + * @returns {[boolean, string?]} + */ +export function tryPlaceEgg(state, opts) { + const { tokenId, x, y, nowUnix } = opts; + const token = consumeToken(state, tokenId); + if (token === null || token === undefined) return [false, "InvalidToken"]; + const eggDef = LootTables.EggTypes[token.eggType]; + if (eggDef === null || eggDef === undefined) throw new Error("ZooService: token contains unknown egg type"); + const nurseryLevel = getNurseryLevelForToken(state, tokenId); + const hatchSeconds = getHatchDurationSeconds(token.eggType, nurseryLevel); + const hatchAt = nowUnix + hatchSeconds; + const seed = Math.floor(Math.random() * 2000000000) + 1; + const [ok, reason] = placeEgg(state, { eggType: token.eggType, tokenId, x, y, hatchAt, seed }); + if (!ok) { + state.pendingEggTokens.push(token); + return [false, reason]; + } + clearNurseryToken(state, tokenId); + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + if (state.stats) state.stats.eggsPlaced = (state.stats.eggsPlaced ?? 0) + 1; + return [true, undefined]; +} + +/** + * @param {import("./types.js").GameState} state + * @returns {[boolean, string?]} + */ +export function tryUpgradePlot(state) { + if (state.plotLevel >= GameConfig.Plot.MaxLevel) return [false, "PlotMaxLevel"]; + const cost = getPlotUpgradeCost(state.plotLevel); + if (state.coins < cost) return [false, "NotEnoughCoins"]; + state.coins -= cost; + state.plotLevel += 1; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + const [width, height] = plotSizeFromLevel(state.plotLevel); + state.grid.width = width; + state.grid.height = height; + if (state.stats) state.stats.plotUpgrades = (state.stats.plotUpgrades ?? 0) + 1; + return [true, undefined]; +} + +/** + * @param {import("./types.js").GameState} state + * @returns {[boolean, string?]} + */ +export function tryUpgradeWorldMap(state) { + const cfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade; + const maxLevel = cfg ? cfg.MaxLevel : 5; + const level = state.worldMapLevel ?? 1; + if (level >= maxLevel) return [false, "WorldMapMaxLevel"]; + const cost = getWorldMapUpgradeResearchCost(level); + const points = state.researchPoints ?? 0; + if (points < cost) return [false, "NotEnoughResearch"]; + state.researchPoints = points - cost; + state.worldMapLevel = level + 1; + state.lastEvolutionAt = Math.floor(Date.now() / 1000); + return [true, undefined]; +}