diff --git a/docs/features/billeterie-flux.md b/docs/features/billeterie-flux.md new file mode 100644 index 0000000..7a0ce92 --- /dev/null +++ b/docs/features/billeterie-flux.md @@ -0,0 +1,25 @@ +# Billeterie – flux complet + +**Objectif :** Flux d’entrée/sortie conforme aux specs : heures d’ouverture 08h–20h, entrée limitée à 1 visiteur/s, départs après durée de séjour, affluence selon l’heure. + +**Référence :** docs/specs/billeterie.md, visiteur.md, inventaire_heures.md ; plan docs/plan-implementation-specs-animaux-billeterie.md. + +## Impacts + +- **Entrée :** uniquement quand `timeOfDay` dans [OpenHour, CloseHour). Nombre de nouveaux visiteurs par tick plafonné par `MaxEntryPerSecond * secondsPerTick`. +- **Départs :** déjà en place (`getStayDurationSeconds`, filtre `arrivedAt + stayDuration`). +- **Demande :** `getVisitorDemand` multiplié par un coefficient selon l’heure (08h–10h faible, 10h–16h fort, 16h–18h décroissant, 18h–20h faible, nuit nul). + +## Modifications + +- **web/js/config.js** : `Billeterie.OpenHour`, `Billeterie.CloseHour`, `Billeterie.MaxEntryPerSecond`. +- **web/js/income.js** : dans `tickVisitorArrivals`, ne pas ajouter de visiteurs si hors créneau ; plafonner les ajouts par tick avec `MaxEntryPerSecond` et `IncomeTickMs` ; `getVisitorDemandHourMultiplier(timeOfDay)` appliqué dans `getVisitorDemand`. + +## Modalités de déploiement + +Client uniquement. Rechargement suffit. + +## Modalités d'analyse + +- Nuit (timeOfDay < 8 ou >= 20) : aucun nouveau visiteur n’entre. +- Jour : nouveaux visiteurs jusqu’à cap et demande, avec au plus 1/s réels (selon tick interval). diff --git a/docs/features/causes-mort-audit.md b/docs/features/causes-mort-audit.md index 043188f..164d216 100644 --- a/docs/features/causes-mort-audit.md +++ b/docs/features/causes-mort-audit.md @@ -28,14 +28,15 @@ ## Non implémenté -- **Seuls** : pas de règle « animal seul meurt ». +- **Seuls** : ~~pas de règle « animal seul meurt ».~~ **Implémenté** : `GameConfig.Animal.MinSameSpeciesInRadius`, `RadiusCells`, `MaxSecondsAlone` ; `checkNotAlone` dans `food.js` ; retrait et `deathCountRecent` si seul depuis trop longtemps. - **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é). +- **Niveau de recherche trop inférieur** : ~~pas de vérification niveau recherche vs niveau animal.~~ **Implémenté** : dans `maybeDeathBlock` (food.js), si `def.rarityLevel > getSkillLevel(state)` → retrait et `deathCountRecent`. +- **Animal (adulte) vente échouée** : ~~à l'expiration d'une annonce adulte (`isBaby: false`), deathCountRecent n'est pas incrémenté.~~ **Implémenté** : `tickSaleListings` (trade.js) incrémente `deathCountRecent` pour les listings expirés adulte comme bébé. ## Fichiers -- `web/js/food.js` : `checkDeathCauses`, `maybeDeathBlock`, `filterPendingBabies`, `filterReceptionAnimals`. -- `web/js/trade.js` : `tickSaleListings` (expiration bébé). +- `web/js/food.js` : `checkDeathCauses`, `maybeDeathBlock`, `checkNotAlone`, `filterPendingBabies`, `filterReceptionAnimals` ; cause recherche (getSkillLevel), cause seuls (Animal config). +- `web/js/trade.js` : `tickSaleListings` (expiration bébé et adulte → deathCountRecent). - `web/js/animal-visits.js` : `lastVisitedAt` pour cause « pas visités ». - `server/db.js` : `expireSaleListings` (bébé invendu). +- `web/js/config.js` : `Animal.MinSameSpeciesInRadius`, `RadiusCells`, `MaxSecondsAlone`. diff --git a/docs/features/feedbacks-visuels-animaux.md b/docs/features/feedbacks-visuels-animaux.md new file mode 100644 index 0000000..f81840c --- /dev/null +++ b/docs/features/feedbacks-visuels-animaux.md @@ -0,0 +1,24 @@ +# Feedbacks visuels animaux + +**Objectif :** États visuels (froid, chaud, faim, malade, heureux) dérivés des données existantes, sans jauges. Specs : animal_generique.md, temperature.md. + +## Impacts + +- **Rendu :** classes CSS `animal-cold`, `animal-hot`, `animal-hungry`, `animal-sick`, `animal-happy` sur les cellules animal (origine et parties de bloc). +- **Logique :** `getAnimalVisualState(cell, state, grid, originKey)` dans animal-visual-state.js : température (avec saison), lastFedAt, lastVisitedAt, seuils config (Food.MaxSecondsWithoutFood, Visitor.MaxSecondsWithoutVisit) pour déduire cold/hot/hungry/sick/happy. + +## Modifications + +- **web/js/animal-visual-state.js** : `getAnimalVisualState` (froid = temp < idéal - tolérance, chaud = temp > idéal + tolérance, hungry = fedAgo > 60 % maxFood, sick = cold ou hot ou hungry ou visitAgo > 80 % maxVisit, happy = aucun problème et bien nourri/visité). +- **web/js/ui.js** : import `getAnimalVisualState`, pour chaque cellule animal (origine ou non) récupération de l’origine et application des classes. +- **web/css/main.css** : `.animal-cold` (teinte bleutée, givre), `.animal-hot` (rougeâtre), `.animal-hungry` (opacité, icône faim), `.animal-sick` (saturé/brillance réduits), `.animal-happy` (lueur, brillance). + +## Modalités de déploiement + +Client uniquement. Rechargement suffit. + +## Modalités d'analyse + +- Animal sur case froide (ou saison hiver) : classe cold visible. +- Animal non nourri depuis longtemps : hungry puis sick. +- Animal bien nourri et visité, temp ok : happy. diff --git a/docs/features/grille-lancement.md b/docs/features/grille-lancement.md index 8b32976..81abb70 100644 --- a/docs/features/grille-lancement.md +++ b/docs/features/grille-lancement.md @@ -16,7 +16,7 @@ - **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. + - `defaultState()` : construit le state puis appelle `addStarterAnimals(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`. diff --git a/docs/features/saisons-phase.md b/docs/features/saisons-phase.md new file mode 100644 index 0000000..4eeeb50 --- /dev/null +++ b/docs/features/saisons-phase.md @@ -0,0 +1,36 @@ +# Saisons – cycle et impacts + +**Objectif :** Cycle de 4 saisons (Printemps, Été, Automne, Hiver) dérivé du jour de jeu, avec impacts sur température, visiteurs, reproduction et billeterie. + +**Référence :** docs/specs/inventaire_saisons.md, temperature.md, visiteur.md, reproduction.md, billeterie.md ; plan docs/plan-implementation-specs-animaux-billeterie.md. + +## Impacts + +- **State :** `gameDayTotal` (incrémenté à chaque jour de jeu dans `tickTime`), `lastSeason`, `seasonChangeMessage` (pour notification). +- **Température :** `getDisplayTemperature` + `getSeasonTemperatureModifier(getCurrentSeason(state))` dans food.js (mort) et reproduction.js (délai naissance). +- **Visiteurs :** `getVisitorDemand` multiplié par `getSeasonVisitorMultiplier(season)`. +- **Reproduction :** délai multiplié par `(1 + getSeasonReproductionBonus(season))` (Printemps +20 %, Hiver -50 %). En hiver, les espèces adaptées au froid (`biome === "Mountain"`) sont exemptées du malus (`getEffectiveReproductionSeasonBonus` dans reproduction.js). +- **Billeterie :** `getVisitorParams` applique `getSeasonTicketPriceMultiplier(season)` sur le paiement entrée (été +20 %, hiver -10 %). +- **UI :** toast « C'est le [Saison] ! » à chaque changement de saison (3 s). + +## Modifications + +- **web/js/config.js** : `Season.DaysPerSeason`, `TemperatureModifier`, `VisitorMultiplier`, `ReproductionBonus`, `TicketPriceMultiplier`. +- **web/js/time-weather.js** : incrément `gameDayTotal` quand `timeOfDay` dépasse 24. +- **web/js/state.js**, **web/js/types.js** : `gameDayTotal`, `lastSeason`, `seasonChangeMessage`. +- **web/js/seasons.js** : `getCurrentSeason`, `getSeasonDay`, `getSeasonTemperatureModifier`, `getSeasonVisitorMultiplier`, `getSeasonReproductionBonus`, `getSeasonTicketPriceMultiplier`. +- **web/js/game-loop.js** : détection changement de saison, pose `seasonChangeMessage`. +- **web/js/food.js** : température effective = base + seasonMod pour `maybeDeathBlock`. +- **web/js/reproduction.js** : temp avec seasonMod, facteur × (1 + seasonBonus) ; `getEffectiveReproductionSeasonBonus(season, def)` pour exonérer Mountain en hiver. +- **web/js/income.js** : demande × seasonVisitorMult, paiement × seasonTicketPriceMult. +- **web/js/ui.js** : toast saison (seasonToastEl), `seasonLabel`, `seasonChangeToast`. +- **web/css/main.css** : `.season-toast`. + +## Modalités de déploiement + +Client uniquement. Rechargement suffit. + +## Modalités d'analyse + +- Avancer le temps (DayLengthSeconds court) jusqu’à changement de jour ; vérifier `gameDayTotal` ; après 7 jours (DaysPerSeason), vérifier changement de saison et toast. +- Vérifier en été : demande visiteurs plus forte, prix ticket plus élevé ; en hiver : demande plus faible, prix ticket plus bas. diff --git a/docs/features/ui-render-extraction.md b/docs/features/ui-render-extraction.md new file mode 100644 index 0000000..ed7d754 --- /dev/null +++ b/docs/features/ui-render-extraction.md @@ -0,0 +1,42 @@ +# Extraction du rendu UI (render) + +## Objectif + +Réduire `web/js/ui.js` pour respecter les règles ESLint : +- max 250 lignes par fichier +- max 40 lignes par fonction + +## Réalisé + +- **no-shadow** : variables `phase` / `weather` dans `updateStatus` renommées en `timePhase` / `weatherVal` ; `mapLevel` dans `fullRender` renommé en `currentMapLevel`. +- **Imports inutilisés** : suppression des imports devenus inutiles après utilisation des modules world-map et grid (zoo, conveyor, economy, placement, texts-fr, api-client) ; suppression de la constante `EGG_EMOJI` non utilisée. +- **Réutilisation des modules** : `render()` utilise désormais `renderWorldMap(ctx)` de `ui-world-map.js` et `renderGrid(ctx)` de `ui-grid.js` au lieu des anciennes fonctions locales (environ 900 lignes supprimées). +- **Refs** : `selectedTokenId`, `emptyCellChoice`, `lastActionWasDrop`, `sellZoneJustDropped` sont passés en refs pour être partagés avec les handlers (grid, sell zone) et les modules. + +# Extraction du rendu UI (render) + +## Objectif + +Réduire `web/js/ui.js` pour respecter les règles ESLint : +- max 250 lignes par fichier +- max 40 lignes par fonction + +## Réalisé + +- **no-shadow** : variables `phase` / `weather` dans `updateStatus` renommées en `timePhase` / `weatherVal` ; `mapLevel` dans `fullRender` renommé en `currentMapLevel`. +- **Imports inutilisés** : suppression des imports devenus inutiles après utilisation des modules world-map et grid (zoo, conveyor, economy, placement, texts-fr, api-client) ; suppression de la constante `EGG_EMOJI` non utilisée. +- **Réutilisation des modules** : `render()` utilise désormais `renderWorldMap(ctx)` de `ui-world-map.js` et `renderGrid(ctx)` de `ui-grid.js` au lieu des anciennes fonctions locales (environ 900 lignes supprimées). +- **Refs** : `selectedTokenId`, `emptyCellChoice`, `lastActionWasDrop`, `sellZoneJustDropped` sont passés en refs pour être partagés avec les handlers (grid, sell zone) et les modules. +- **Extraction en modules** : + - `ui-render-dom.js` : `buildUIDOM`, `createTabsStructure`, `updateStatusBody`, `createUpdateStatus`, `createFullRender`, `updateWorldMapUpgradeAndCounters`, `buildFinishContexts`, `finishBuildUIDOM` ; `formatQuestListHtml` pour garder `updateStatusBody` sous 40 lignes ; `finishBuildUIDOM` prend un objet `opts` (max-params) et délègue les contextes à `buildFinishContexts`. + - `ui-render-dom-panels.js` : `buildWorldMapWrap`, `buildWorldMapUpgradeZone`, `buildWorldMapTruckDropZone`, `buildWorldMapActions`, `buildWorldMapSection`, `buildPlotUpgradeZone`, `buildGridSection`, `attachSellZoneListeners`, `handleSellZoneClick` ; import de `handleWorldMapTruckDrop` et `handleSellZoneDrop` depuis `ui-render-dom-drops.js`. + - `ui-render-dom-drops.js` (nouveau) : handlers de drop pour la carte du monde (camion) et la sell zone ; `handleWorldMapTruckDrop`, `handleSellZoneDrop` exportés ; sous-handlers `handleTruckDropBaby`, `handleTruckDropAnimal`, `handleTruckDropEgg`, `applyNurseryDrop`, `applyReceptionDrop`, `applyGridCellSell` pour rester sous 40 lignes et réduire la complexité. + - `ui-render-gamebar.js` : `buildGameBar` et helpers (title, status bar, view switcher, music, auto mode, prestige/restart, quest dropdown) ; import de `buildAutoProfilePicker` depuis `ui-render-gamebar-picker.js`. + - `ui-render-gamebar-picker.js` (nouveau) : `buildAutoProfilePicker` avec `buildAutoProfilePickerFamilyStep` et `buildAutoProfilePickerSpecStep` pour respecter max-lines-per-function et max-lines du fichier gamebar. + - `ui.js` : `EMOJI_BY_COLOR`, `animalEmoji`, `buildRenderSetup(opts)`, `render(root, opts)` qui appelle `buildUIDOM(root, setup)` ; fichier et fonctions sous les limites. +- **Imports nettoyés** : `getPlotUpgradeCost` retiré de panels ; `t` retiré de ui-render-dom ; imports inutilisés retirés de ui-render-gamebar (getSkillLevel, getVisitorCount, getTimePhase, doPrestige, playSound, errorMessage, questDescription, timePhaseLabel, weatherLabel, GameConfig ; textes et auto-mode-profiles conservés dans le picker). + +## État actuel + +- **ESLint** : 0 erreur sur les fichiers modifiés. Les warnings (complexity, etc.) restants sont hors périmètre de cette extraction. +- **Fichiers** : `ui.js`, `ui-render-dom.js`, `ui-render-dom-panels.js`, `ui-render-dom-drops.js`, `ui-render-gamebar.js`, `ui-render-gamebar-picker.js` respectent max-lines (250) et max-lines-per-function (40). diff --git a/docs/leo.md b/docs/leo.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/plan-implementation-rappel-grandes-regles.md b/docs/plan-implementation-rappel-grandes-regles.md deleted file mode 100644 index d100b03..0000000 --- a/docs/plan-implementation-rappel-grandes-regles.md +++ /dev/null @@ -1,321 +0,0 @@ -# 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-implementation-specs-animaux-billeterie.md b/docs/plan-implementation-specs-animaux-billeterie.md new file mode 100644 index 0000000..82c356b --- /dev/null +++ b/docs/plan-implementation-specs-animaux-billeterie.md @@ -0,0 +1,192 @@ +# Plan d'implémentation – Specs Animaux, Morts, Saisons, Billeterie + +Références : `docs/plan-action-cahier-des-charges.md` (§7 Animaux/morts/saisons, §8 Billeterie), `docs/features/causes-mort-audit.md`, et tous les fichiers de `docs/specs/` concernés. + +--- + +## Spécifications couvertes + +| Spec | Périmètre implémenté | +|------|----------------------| +| **animal_generique.md** | États faim/température/santé ; feedbacks visuels (froid=bleu/givre, chaud=rouge/vapeur, faim=icône, heureux=cœurs) ; pas de jauges. | +| **mort_bebe.md** | Causes déjà en place ; conséquence mort adulte vente échouée (aligné). | +| **inventaire_saisons.md** | Cycle 4 saisons ; modificateurs météo/température/reproduction/survie/visiteurs. | +| **temperature.md** | Modificateur saison (+0 Printemps, +10 Été, -2 Automne, -15 Hiver) ; modificateur jour/nuit (+5 jour, -5 nuit) ; feedback critique (spec déjà partiellement en place via tolerance). | +| **reproduction.md** | Impact saisons : Printemps +20% chance, Hiver -50% (sauf animaux froids). | +| **billeterie.md** | Flux entrée/sortie ; capacité 20/unité ; ouverture 08h–20h ; 1 visiteur/s max entrée ; modificateurs saison (été +20% prix, hiver -10%). | +| **visiteur.md** | Arrivée par billeterie ; durée max (1 journée / stayDuration) ; départ par billeterie ; affluence selon heure (08h–10h faible, 10h–16h fort, >18h nul) ; multiplicateur saison (été x1.5, hiver x0.6, etc.). | +| **inventaire_heures.md** | Cycle jour/nuit (déjà timeOfDay) ; billeterie fermée la nuit. | +| **centre_recherche.md** | Niveau recherche (skill level) : si rareté animal > niveau recherche → mort (cause « niveau de recherche trop inférieur »). | +| **causes-mort-audit.md** | Seuls (animal seul meurt) ; recherche trop basse ; vente adulte échouée (deathCountRecent). | + +--- + +## Phase 1 – Causes de mort manquantes + +**Objectif :** Compléter les causes de mort listées dans `docs/features/causes-mort-audit.md` (Non implémenté). + +### 1.1 Vente adulte échouée + +- **Fichier :** `web/js/trade.js` +- **Changement :** Dans `tickSaleListings`, lorsque `nowUnix >= listing.endAt` et `listing.isBaby === false`, incrémenter `state.deathCountRecent` (comme pour les bébés). +- **Spec :** causes-mort-audit §9. + +### 1.2 Niveau de recherche trop inférieur + +- **Fichier :** `web/js/food.js` (ou module commun appelé par `checkDeathCauses`) +- **Logique :** Pour chaque bloc animal sur la grille, récupérer `LootTables.Animals[cell.id].rarityLevel` et le comparer à `getSkillLevel(state)` (depuis `conveyor.js`). Si `rarityLevel > getSkillLevel(state)`, retirer le bloc et incrémenter `deathCountRecent`. +- **Config optionnelle :** `GameConfig.Research?.MaxRarityAllowed` ou utiliser directement skill level (1–8) vs rarityLevel (1–5) ; si le projet utilise skill level comme “niveau école” et que la spec centre_recherche parle de “rareté visible”, interpréter : animal de rareté > niveau compétence → mort. +- **Fichiers :** `web/js/food.js`, `web/js/config.js` (si config ajoutée), `docs/features/causes-mort-audit.md` (mise à jour). + +### 1.3 Animal seul (solitude) + +- **Spec :** animal_generique “Besoin de congénères (reproduction) ou de solitude (selon espèce)” ; causes-mort-audit §1 “Seuls”. +- **Interprétation :** Au moins une règle “animal seul meurt” : si aucun autre animal de la même espèce (même `cell.id`) dans un rayon de N cases (ex. 5), après un délai configurable, retirer l’animal et incrémenter `deathCountRecent`. +- **Fichiers :** `web/js/config.js` (`Animal.MinSameSpeciesInRadius`, `Animal.MaxSecondsAlone`), `web/js/food.js` dans `checkDeathCauses` (ou helper) : pour chaque origine animal, compter les autres blocs même `id` dans le rayon ; si 0 et `nowUnix - placedAt > MaxSecondsAlone`, retirer. +- **État :** Optionnel si “selon espèce” complique (toutes espèces = besoin congénères par défaut). + +--- + +## Phase 2 – Config et données saisons + +**Objectif :** Introduire le cycle de saisons et la config sans encore brancher tous les impacts. + +### 2.1 Config saisons + +- **Fichier :** `web/js/config.js` +- **Ajout :** `Season: { DayLengthSeconds: 120, DaysPerSeason: 7 }` (ou 30 jours in-game). `Season.TemperatureModifier: { spring: 0, summer: 10, autumn: -2, winter: -15 }`. `Season.VisitorMultiplier: { spring: 1, summer: 1.5, autumn: 0.8, winter: 0.6 }`. `Season.ReproductionBonus: { spring: 0.2, summer: 0, autumn: 0, winter: -0.5 }` (Hiver -50% sauf animaux froids à traiter dans reproduction). +- **Référence :** `docs/specs/inventaire_saisons.md`, `temperature.md`, `visiteur.md`, `reproduction.md`. + +### 2.2 State et temps de jeu “jour” + +- **Fichier :** `web/js/types.js` +- **Ajout :** `gameDay?: number` (jour de jeu absolu, dérivé de `timeOfDay` et `DayLengthSeconds`) ou `season?: 'spring'|'summer'|'autumn'|'winter'`, `seasonDay?: number` (jour dans la saison). +- **Fichier :** `web/js/time-weather.js` ou nouveau `web/js/seasons.js` +- **Logique :** À partir de `state.timeOfDay` et `GameConfig.Time.DayLengthSeconds`, calculer un “game day” (entier). À partir de `gameDay` et `DaysPerSeason`, calculer `season` et `seasonDay`. Exporter `getCurrentSeason(state)`, `getSeasonTemperatureModifier(season)`, `getSeasonVisitorMultiplier(season)`, `getSeasonReproductionBonus(season)`. + +--- + +## Phase 3 – Cycle saisons et impacts + +**Objectif :** Brancher la saison sur température, reproduction, visiteurs, billeterie. + +### 3.1 Température affichée / calculée avec saison + +- **Fichiers :** `web/js/placement.js` ou module température existant (ex. `getDisplayTemperature` dans food.js / grid-utils) +- **Changement :** `currentTemp = baseBiomeTemp + seasonMod + dayNightMod + caseRegulatorOffset`. Récupérer `seasonMod` depuis `getSeasonTemperatureModifier(getCurrentSeason(state))`. +- **Spec :** `temperature.md` (Impact Saisons, Impact Heure / Jour-Nuit). + +### 3.2 Reproduction et saison + +- **Fichier :** `web/js/reproduction.js` +- **Changement :** Intégrer un bonus/malus de saison (Printemps +20%, Hiver -50% sauf animaux “froids” selon biome) dans le calcul de chance de reproduction. +- **Spec :** `reproduction.md`, `inventaire_saisons.md`. + +### 3.3 Visiteurs et saison + +- **Fichier :** `web/js/income.js` +- **Changement :** Dans `getVisitorDemand`, multiplier par `getSeasonVisitorMultiplier(getCurrentSeason(state))`. +- **Spec :** `visiteur.md` (Impact Saisons). + +### 3.4 Billeterie – modificateur prix ticket par saison + +- **Fichier :** `web/js/income.js` (ou economy) +- **Changement :** Prix ticket base ; si saison été : *1.2 ; si saison hiver : *0.9. Appliquer dans le calcul de `paymentPerVisitor` (entrée). +- **Spec :** `billeterie.md` (Impact Saisons). + +### 3.5 Notification changement de saison + +- **Fichier :** `web/js/ui.js` ou game-loop +- **Changement :** Lors d’un changement de saison (comparer `getCurrentSeason(state)` à une valeur stockée), afficher un message type “C’est le [Saison] !” (toast ou texte temporaire). Stocker `lastSeason` dans le state si nécessaire. +- **Spec :** `inventaire_saisons.md` (Messages SEASON_CHANGE). + +--- + +## Phase 4 – Flux billeterie complet + +**Objectif :** Heures d’ouverture, départs explicites, limite 1 visiteur/s à l’entrée. + +### 4.1 Heures d’ouverture (08h–20h) + +- **Fichier :** `web/js/config.js` +- **Ajout :** `Billeterie.OpenHour: 8`, `Billeterie.CloseHour: 20` (ou 20h = fermeture, pas d’entrée après). +- **Fichier :** `web/js/income.js` +- **Changement :** Dans `tickVisitorArrivals`, ne pas ajouter de nouveaux visiteurs si `timeOfDay` est en dehors de [8, 20] (ou équivalent en phase “night”). Utiliser `getTimePhase(state.timeOfDay)` ; si phase “night” ou `timeOfDay < 8` ou `timeOfDay >= 20`, ne pas pousser de nouveaux `{ arrivedAt }`. +- **Spec :** `billeterie.md`, `inventaire_heures.md`. + +### 4.2 Départs par billeterie (durée max “1 journée”) + +- **Déjà en place :** `getStayDurationSeconds` et filtre `nowUnix < v.arrivedAt + stayDuration`. S’assurer que la durée est bien “1 journée” (DayLengthSeconds × facteur) selon spec. Optionnel : faire partir les visiteurs vers la “sortie” (billeterie) visuellement. +- **Spec :** `visiteur.md` (Durée max, Départ). + +### 4.3 Limite 1 visiteur / seconde à l’entrée + +- **Fichier :** `web/js/income.js` +- **Changement :** Dans `tickVisitorArrivals`, au lieu d’ajouter `target - current` d’un coup, limiter le nombre d’entrées sur ce tick à au plus `ceil(dt)` ou 1 par tick si tick = 1s, ou stocker `lastVisitorSpawnAt` et n’ajouter qu’un visiteur par seconde réelle. Adapter selon `IncomeTickMs` (ex. si tick 5s, ajouter au plus 5 nouveaux visiteurs par tick, ou 1 par seconde en interpolant). +- **Spec :** `billeterie.md` (“1 visiteur / seconde max”). + +### 4.4 Affluence selon l’heure (optionnel) + +- **Fichier :** `web/js/income.js` +- **Changement :** Dans `getVisitorDemand`, appliquer un coefficient selon `timeOfDay` : 08h–10h faible, 10h–16h fort, 16h–18h décroissant, >18h nul (ou très faible). Utiliser `state.timeOfDay` et `getTimePhase`. +- **Spec :** `visiteur.md` (Impact Heure / Jour-Nuit). + +--- + +## Phase 5 – Feedbacks visuels animaux + +**Objectif :** Pas de jauges ; états visuels dérivés des données existantes (température, nourriture, visite, reproduction). + +### 5.1 Détection des états par cellule animal + +- **Fichier :** nouveau `web/js/animal-visual-state.js` ou dans `web/js/ui.js` / module rendu +- **Logique :** Pour une cellule animal (origine) : `getDisplayTemperature`, `lastFedAt`, `lastVisitedAt`, état reproduction (paire proche). Exporter `getAnimalVisualState(cell, state, grid)` → `{ cold: boolean, hot: boolean, hungry: boolean, sick: boolean, happy: boolean }` (sick si proche mort / mal nourri ; happy si récemment visité et nourri et temp ok, ou en reproduction). +- **Spec :** `animal_generique.md`, `temperature.md` (Feedback critique). + +### 5.2 Rendu CSS / classes + +- **Fichier :** `web/js/ui.js` (rendu grille) +- **Changement :** Pour chaque bloc animal rendu, ajouter des classes CSS selon `getAnimalVisualState` : ex. `animal-cold`, `animal-hot`, `animal-hungry`, `animal-sick`, `animal-happy`. Pas de jauges. +- **Fichier :** `web/css/main.css` +- **Changement :** Styles pour ces classes : teinte bleutée/givre (froid), rougeâtre/vapeur (chaud), icône faim (lent/maigre), ternes/mouches (malade), cœurs/couleurs vives (heureux). Utiliser filter, box-shadow ou pseudo-éléments pour icônes. +- **Spec :** `animal_generique.md` (Vie Quotidienne), `temperature.md` (Feedback critique). + +--- + +## Phase 6 – Documentation et clôture + +- Mettre à jour `docs/features/causes-mort-audit.md` (causes implémentées : seuls, recherche, vente adulte). +- Créer ou mettre à jour `docs/features/saisons-phase.md` (cycle, config, impacts). +- Créer ou mettre à jour `docs/features/billeterie-flux.md` (heures, 1/s, départs). +- Créer `docs/features/feedbacks-visuels-animaux.md` (états, classes CSS, pas de jauges). +- Vérifier lint, types, compilation. + +--- + +## Ordre d’exécution recommandé + +1. **Phase 1** – Causes de mort (trade.js adulte, food.js recherche, optionnel solitude). +2. **Phase 2** – Config et module saisons (config.js, seasons.js, types.js). +3. **Phase 3** – Impacts saisons (température, reproduction, visiteurs, billeterie, notification). +4. **Phase 4** – Flux billeterie (heures, 1/s, affluence heure). +5. **Phase 5** – Feedbacks visuels animaux (animal-visual-state.js, ui.js, main.css). +6. **Phase 6** – Documentation. + +--- + +## Fichiers principaux impactés + +| Fichier | Phases | +|---------|--------| +| `web/js/trade.js` | 1.1 | +| `web/js/food.js` | 1.2, 1.3 | +| `web/js/config.js` | 1.2, 1.3, 2.1, 4.1 | +| `web/js/types.js` | 2.2 | +| `web/js/time-weather.js` ou `web/js/seasons.js` | 2.2, 3.1 | +| `web/js/placement.js` ou module température | 3.1 | +| `web/js/reproduction.js` | 3.2 | +| `web/js/income.js` | 3.3, 3.4, 4.1, 4.3, 4.4 | +| `web/js/ui.js` | 3.5, 5.2 | +| `web/js/animal-visual-state.js` (nouveau) | 5.1 | +| `web/css/main.css` | 5.2 | +| `docs/features/*.md` | 6 | diff --git a/docs/plan-verification-phases-0-a-9.md b/docs/plan-verification-phases-0-a-9.md deleted file mode 100644 index 75b68b7..0000000 --- a/docs/plan-verification-phases-0-a-9.md +++ /dev/null @@ -1,131 +0,0 @@ -# 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/package-lock.json b/package-lock.json index 5365301..626cb65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -260,7 +260,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -469,7 +468,6 @@ "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", diff --git a/package.json b/package.json index c05c20d..7f01cd9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "lint": "eslint web/js server --ignore-pattern '**/node_modules/**'", "lint:web": "eslint web/js", - "lint:server": "eslint server --ignore-pattern 'server/node_modules/**'" + "lint:server": "eslint server --ignore-pattern 'server/node_modules/**'", + "type-check": "npm run lint" }, "devDependencies": { "eslint": "^9.15.0", diff --git a/server/db-core.js b/server/db-core.js new file mode 100644 index 0000000..dd0e171 --- /dev/null +++ b/server/db-core.js @@ -0,0 +1,227 @@ +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] + ); +} + +export { pool }; diff --git a/server/db-sales.js b/server/db-sales.js new file mode 100644 index 0000000..04e45fd --- /dev/null +++ b/server/db-sales.js @@ -0,0 +1,267 @@ +import { pool, getZooById, updateZooGameState } from "./db-core.js"; + +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), + }; +} + +/** + * @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 }; +} + +/** + * @param {object} listing + * @returns {Promise<{ ok: true, buyerZoo: object, sellerZoo: object } | { ok: false, reason: string }>} + */ +async function validateBuyerAndSeller(listing) { + 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(listing.seller_zoo_id); + if (!buyerZoo || !buyerZoo.game_state) return { ok: false, reason: "BuyerStateMissing" }; + if (!sellerZoo || !sellerZoo.game_state) return { ok: false, reason: "SellerStateMissing" }; + const buyerCoins = Number(buyerZoo.game_state.coins ?? 0); + if (buyerCoins < amount) return { ok: false, reason: "BuyerInsufficientCoins" }; + return { ok: true, buyerZoo, sellerZoo }; +} + +/** + * 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 parties = await validateBuyerAndSeller(validated.listing); + if (!parties.ok) return { ok: false, reason: parties.reason }; + 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 }; +} +/** + * 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 }; +} + +/** + * 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; +} diff --git a/server/db.js b/server/db.js index e6aa5c6..97cd62a 100644 --- a/server/db.js +++ b/server/db.js @@ -1,489 +1,29 @@ -import pg from "pg"; -import parse from "pg-connection-string"; -import { createInitialBotState } from "./bot-state.js"; +export { + pool, + getMapParams, + getAccountByPublicKey, + createAccount, + updateLastSeen, + getZooByAccountId, + getAllZoos, + createZoo, + getZooById, + updateZooGameState, + countPlayerZoos, + createBotZoo, + getBotZoosForTick, + updateBotZooState, +} from "./db-core.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 }; +export { + createSaleListing, + getSaleListingById, + getActiveSaleListings, + getSalesForZoo, + placeBid, + acceptSale, + processValidatedSales, + rejectSale, + markSaleDelivered, + expireSaleListings, +} from "./db-sales.js"; diff --git a/server/routes/sales.js b/server/routes/sales.js index 891ce5e..774f9b3 100644 --- a/server/routes/sales.js +++ b/server/routes/sales.js @@ -17,6 +17,35 @@ import { verifySignature, buildSignMessage, hashBody } from "../auth.js"; const router = express.Router(); const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; +/** @param {string} timestamp + * @returns {boolean} + */ +function isTimestampValid(timestamp) { + const now = Date.now(); + const ts = new Date(timestamp).getTime(); + return !Number.isNaN(ts) && Math.abs(now - ts) <= TIMESTAMP_TOLERANCE_MS; +} + +/** @param {import("express").Request} req + * @returns {string} + */ +function getBodyForSignature(req) { + return req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : ""; +} + +/** @param {import("express").Request} req + * @param {string} publicKey + * @param {string} signature + * @param {string} timestamp + * @returns {boolean} + */ +function isSignatureValid(req, publicKey, signature, timestamp) { + const bodyHash = hashBody(getBodyForSignature(req)); + const path = req.originalUrl || req.baseUrl + req.path || req.path; + const message = buildSignMessage(req.method, path, timestamp, bodyHash); + return verifySignature(publicKey, signature, message); +} + function requireSignature() { return (req, res, next) => { const publicKey = req.headers["x-public-key"]; @@ -26,17 +55,11 @@ function requireSignature() { 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) { + if (!isTimestampValid(timestamp)) { 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)) { + if (!isSignatureValid(req, publicKey, signature, timestamp)) { res.status(401).json({ error: "Invalid signature" }); return; } @@ -60,17 +83,7 @@ function optionalSignature() { 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)) { + if (!isTimestampValid(timestamp) || !isSignatureValid(req, publicKey, signature, timestamp)) { next(); return; } @@ -81,28 +94,62 @@ function optionalSignature() { }; } -/** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only. */ +/** GET /api/sales — optional auth: with auth returns asSeller, asBuyerUndelivered, active; without auth returns active only. + * @param {import("express").Request} req + * @returns {Promise<{ active: Array } | { asSeller: Array, asBuyerUndelivered: Array, active: Array }>} + */ +async function getSalesResponse(req) { + await expireSaleListings(); + if (!req.account) { + const active = await getActiveSaleListings(); + return { active }; + } + await processValidatedSales(); + const zoo = await getZooByAccountId(req.account.id); + if (!zoo) { + return { asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() }; + } + return getSalesForZoo(zoo.id); +} + 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 }); + const data = await getSalesResponse(req); + res.json(data); } catch (e) { next(e); } }); +/** + * @param {unknown} body + * @returns {{ status: number, error: string } | null} + */ +function validateCreateListingBody(body) { + const { animalId, isBaby, price, endAt } = body ?? {}; + if (typeof animalId !== "string" || !animalId.trim()) return { status: 400, error: "animalId required" }; + if (typeof isBaby !== "boolean") return { status: 400, error: "isBaby boolean required" }; + const initialPrice = Number(price); + if (!Number.isFinite(initialPrice) || initialPrice < 0) return { status: 400, error: "price must be a non-negative number" }; + const endAtDate = endAt ? new Date(endAt) : null; + if (!endAtDate || Number.isNaN(endAtDate.getTime())) return { status: 400, error: "endAt required (ISO date string)" }; + return null; +} + +/** + * @param {unknown} body + * @returns {{ ok: true, animalId: string, isBaby: boolean, initialPrice: number, endAtDate: Date, reproductionScoreAtSale?: number } | { ok: false, status: number, error: string }} + */ +function parseCreateListingBody(body) { + const err = validateCreateListingBody(body); + if (err) return { ok: false, status: err.status, error: err.error }; + const { animalId, isBaby, price, endAt, reproductionScoreAtSale } = body ?? {}; + const endAtDate = endAt ? new Date(endAt) : null; + const initialPrice = Number(price); + const repScore = reproductionScoreAtSale !== null && reproductionScoreAtSale !== undefined ? Number(reproductionScoreAtSale) : undefined; + return { ok: true, animalId: String(animalId).trim(), isBaby, initialPrice, endAtDate: /** @type {Date} */ (endAtDate), reproductionScoreAtSale: repScore }; +} + /** POST /api/sales — create listing (auth). Body: { animalId, isBaby, price, endAt, reproductionScoreAtSale? }. */ router.post("/", requireSignature(), async (req, res, next) => { try { @@ -111,32 +158,18 @@ router.post("/", requireSignature(), async (req, res, next) => { 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)" }); + const parsed = parseCreateListingBody(req.body); + if (!parsed.ok) { + res.status(parsed.status).json({ error: parsed.error }); return; } const { id } = await createSaleListing({ sellerZooId: zoo.id, - animalId: animalId.trim(), - isBaby, - initialPrice, - endAt: endAtDate.toISOString(), - reproductionScoreAtSale: reproductionScoreAtSale != null ? Number(reproductionScoreAtSale) : undefined, + animalId: parsed.animalId, + isBaby: parsed.isBaby, + initialPrice: parsed.initialPrice, + endAt: parsed.endAtDate.toISOString(), + reproductionScoreAtSale: parsed.reproductionScoreAtSale, }); res.status(201).json({ id }); } catch (e) { diff --git a/server/routes/zoos.js b/server/routes/zoos.js index 7a85034..7657fff 100644 --- a/server/routes/zoos.js +++ b/server/routes/zoos.js @@ -15,6 +15,23 @@ import { verifySignature, buildSignMessage, hashBody } from "../auth.js"; const router = express.Router(); const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; +function isTimestampValid(timestamp) { + const now = Date.now(); + const ts = new Date(timestamp).getTime(); + return !Number.isNaN(ts) && Math.abs(now - ts) <= TIMESTAMP_TOLERANCE_MS; +} + +function getBodyForSignature(req) { + return req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : ""; +} + +function isSignatureValid(req, publicKey, signature, timestamp) { + const bodyHash = hashBody(getBodyForSignature(req)); + const path = req.originalUrl || req.baseUrl + req.path || req.path; + const message = buildSignMessage(req.method, path, timestamp, bodyHash); + return verifySignature(publicKey, signature, message); +} + function requireSignature() { return (req, res, next) => { const publicKey = req.headers["x-public-key"]; @@ -24,17 +41,11 @@ function requireSignature() { 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) { + if (!isTimestampValid(timestamp)) { 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)) { + if (!isSignatureValid(req, publicKey, signature, timestamp)) { res.status(401).json({ error: "Invalid signature" }); return; } @@ -49,20 +60,31 @@ function requireSignature() { }; } +/** @returns {Promise<{ zoos: Array, mapWidth: number, mapHeight: number }>} + */ +async function getZoosForMap() { + 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(); + return { + zoos, + mapWidth: params.mapWidth, + mapHeight: params.mapHeight, + }; +} + /** 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 { zoos, mapWidth, mapHeight } = await getZoosForMap(); const worldZoos = zoos.map((z) => ({ id: z.id, name: z.name, @@ -71,7 +93,7 @@ router.get("/", async (req, res, next) => { animalWeights: z.animal_weights, game_state: z.game_state ?? null, })); - res.json({ worldZoos, mapWidth: params.mapWidth, mapHeight: params.mapHeight }); + res.json({ worldZoos, mapWidth, mapHeight }); } catch (e) { next(e); } diff --git a/web/css/main.css b/web/css/main.css index 6a7d2f6..614fa00 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -1374,6 +1374,30 @@ body.bg-phase-night.bg-weather-rain { background: linear-gradient(160deg, #080a0 font-weight: 600; } +.cell.animal.animal-cold { + filter: hue-rotate(-20deg) saturate(0.9) brightness(0.95); + box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.25); +} +.cell.animal.animal-hot { + filter: hue-rotate(10deg) saturate(1.1) brightness(1.05); + box-shadow: inset 0 0 8px rgba(255, 100, 80, 0.3); +} +.cell.animal.animal-hungry { + opacity: 0.92; +} +.cell.animal.animal-hungry .cell-label::before { + content: "🍽️ "; + font-size: 0.5rem; +} +.cell.animal.animal-sick { + filter: saturate(0.6) brightness(0.85); + opacity: 0.9; +} +.cell.animal.animal-happy { + box-shadow: 0 0 12px rgba(255, 215, 0, 0.4); + filter: brightness(1.08) saturate(1.05); +} + .cell .cell-emoji { font-size: 1.4rem; display: block; @@ -1445,6 +1469,18 @@ body.bg-phase-night.bg-weather-rain { background: linear-gradient(160deg, #080a0 min-height: 0; } +.season-toast { + padding: 6px 10px; + font-size: 0.9rem; + background: rgba(63, 185, 80, 0.15); + color: #238636; + border-radius: 6px; + margin-top: 4px; +} +.season-toast[hidden] { + display: none; +} + /* Bulles d'aide */ .help-wrap { position: relative; diff --git a/web/js/animal-visual-state.js b/web/js/animal-visual-state.js new file mode 100644 index 0000000..e3d548f --- /dev/null +++ b/web/js/animal-visual-state.js @@ -0,0 +1,101 @@ +/** + * Visual state of an animal cell for feedback (no gauges). Used by UI to add CSS classes. + * Refs: docs/specs/animal_generique.md, temperature.md. + */ + +import { LootTables } from "./loot-tables.js"; +import { getDisplayTemperature } from "./biome-rules.js"; +import { getCurrentSeason, getSeasonTemperatureModifier } from "./seasons.js"; +import { GameConfig } from "./config.js"; + +/** + * @param {number} cellTemp + * @param {number} idealTemp + * @param {number} tolerance + * @returns {{ cold: boolean, hot: boolean }} + */ +function getTemperatureState(cellTemp, idealTemp, tolerance) { + return { + cold: cellTemp < idealTemp - tolerance, + hot: cellTemp > idealTemp + tolerance, + }; +} + +/** + * @param {{ fedAgo: number, visitAgo: number, maxFood: number, maxVisit: number, cold: boolean, hot: boolean }} opts + * @returns {{ hungry: boolean, sick: boolean, happy: boolean }} + */ +function getCareState(opts) { + const { fedAgo, visitAgo, maxFood, maxVisit, cold, hot } = opts; + const hungry = fedAgo > maxFood * 0.6; + const sick = cold || hot || hungry || visitAgo > maxVisit * 0.8; + const happy = !sick && fedAgo < maxFood * 0.3 && visitAgo < maxVisit * 0.3 + && !cold && !hot; + return { hungry, sick, happy }; +} + +const EMPTY_VISUAL = { cold: false, hot: false, hungry: false, sick: false, happy: false }; + +/** + * @param {import("./types.js").AnimalCell} cell + * @param {import("./types.js").GameState} state + * @param {{ width: number, height: number }} grid + * @param {string} originKey + * @returns {{ cellTemp: number, idealTemp: number, tolerance: number } | null} + */ +function getAnimalTempInputs(cell, state, grid, originKey) { + const def = LootTables.Animals[cell.id]; + if (!def) return null; + const m = originKey.match(/^(\d+)_(\d+)$/); + if (!m) return null; + const ox = Number(m[1]); + const oy = Number(m[2]); + const baseTemp = getDisplayTemperature(ox, oy, grid); + const seasonMod = getSeasonTemperatureModifier(getCurrentSeason(state)); + const cellTemp = baseTemp + seasonMod; + const idealTemp = def.idealTemperature ?? 18; + const tolerance = def.temperatureTolerance ?? 5; + return { cellTemp, idealTemp, tolerance }; +} + +/** + * @param {import("./types.js").AnimalCell} cell + * @returns {{ fedAgo: number, visitAgo: number, maxFood: number, maxVisit: number }} + */ +function getAnimalTimeInputs(cell) { + const nowUnix = Math.floor(Date.now() / 1000); + const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix; + const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix; + const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120; + const maxVisit = GameConfig.Visitor?.MaxSecondsWithoutVisit ?? 300; + return { fedAgo: nowUnix - lastFed, visitAgo: nowUnix - lastVisited, maxFood, maxVisit }; +} + +/** + * @param {import("./types.js").AnimalCell} cell + * @param {import("./types.js").GameState} state + * @param {{ width: number, height: number }} grid + * @param {string} originKey + * @returns {{ cellTemp: number, idealTemp: number, tolerance: number, fedAgo: number, visitAgo: number, maxFood: number, maxVisit: number } | null} + */ +function getAnimalVisualInputs(cell, state, grid, originKey) { + const temp = getAnimalTempInputs(cell, state, grid, originKey); + if (!temp) return null; + const time = getAnimalTimeInputs(cell); + return { ...temp, ...time }; +} + +/** + * @param {import("./types.js").AnimalCell} cell Origin animal cell. + * @param {import("./types.js").GameState} state + * @param {{ width: number, height: number }} grid + * @param {string} originKey "x_y" of origin cell. + * @returns {{ cold: boolean, hot: boolean, hungry: boolean, sick: boolean, happy: boolean }} + */ +export function getAnimalVisualState(cell, state, grid, originKey) { + const inputs = getAnimalVisualInputs(cell, state, grid, originKey); + if (!inputs) return EMPTY_VISUAL; + const { cold, hot } = getTemperatureState(inputs.cellTemp, inputs.idealTemp, inputs.tolerance); + const { hungry, sick, happy } = getCareState({ fedAgo: inputs.fedAgo, visitAgo: inputs.visitAgo, maxFood: inputs.maxFood, maxVisit: inputs.maxVisit, cold, hot }); + return { cold, hot, hungry, sick, happy }; +} diff --git a/web/js/biome-rules.js b/web/js/biome-rules.js index e589009..8497a69 100644 --- a/web/js/biome-rules.js +++ b/web/js/biome-rules.js @@ -8,16 +8,14 @@ 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} _height * @param {number} x 1-based column - * @param {number} y 1-based row + * @param {number} _y 1-based row * @returns {string} */ -export function getCellBiome(width, height, x, y) { +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"; diff --git a/web/js/bot-zoo.js b/web/js/bot-zoo.js index ea3f0f8..8451cde 100644 --- a/web/js/bot-zoo.js +++ b/web/js/bot-zoo.js @@ -55,6 +55,16 @@ export function getZooSkillLevel(zoo) { return b ? b.conveyorLevel : 1; } +/** + * @param {Record} out + * @param {Record} neighborWeights + * @param {string[]} colorNames + * @returns {void} + */ +function addNeighborWeights(out, neighborWeights, colorNames) { + for (const c of colorNames) out[c] = (out[c] ?? 0) + (neighborWeights[c] ?? 0); +} + /** * Neighbor color weights (sum of animalWeights of zoos within maxDistance on the map). * @param {import("./types.js").GameState} state @@ -73,10 +83,7 @@ export function getNeighborColorWeights(state, 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); - } + if (dist <= maxD) addNeighborWeights(out, z.animalWeights ?? {}, colorNames); } } return out; @@ -150,27 +157,13 @@ export function ensureBotState(zoo, isPlayer) { } /** - * @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 + * @param {import("./types.js").BotState} b + * @param {string} choice + * @param {{ plotCost: number, skillCost: number, truckCost: number }} costs * @returns {boolean} */ -function botDecideUpgrade(state, zoo, opts) { - const { b, rng, params } = opts; - const { spendThreshold, upgradeChance } = params; - const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels(); - const plotCost = getPlotUpgradeCost(b.plotLevel); - const skillCost = getSchoolUpgradeCost(b.conveyorLevel); - const truckCost = getTruckUpgradeCost(b.truckLevel); - 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)]; +function applyBotUpgradeChoice(b, choice, costs) { + const { plotCost, skillCost, truckCost } = costs; if (choice === "plot" && b.coins >= plotCost) { b.coins -= plotCost; b.plotLevel += 1; @@ -189,6 +182,28 @@ function botDecideUpgrade(state, zoo, opts) { return false; } +/** + * @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 { plotMax, skillMax, truckMax } = getUpgradeMaxLevels(); + const plotCost = getPlotUpgradeCost(b.plotLevel); + const skillCost = getSchoolUpgradeCost(b.conveyorLevel); + const truckCost = getTruckUpgradeCost(b.truckLevel); + const upgradeChoices = []; + if (b.plotLevel < plotMax && b.coins >= plotCost * spendThreshold) upgradeChoices.push("plot"); + if (b.conveyorLevel < skillMax && b.coins >= skillCost * spendThreshold) upgradeChoices.push("skill"); + if (b.truckLevel < truckMax && b.coins >= truckCost * spendThreshold) upgradeChoices.push("truck"); + if (upgradeChoices.length === 0 || rng() >= upgradeChance) return false; + const choice = upgradeChoices[Math.floor(rng() * upgradeChoices.length)]; + return applyBotUpgradeChoice(b, choice, { plotCost, skillCost, truckCost }); +} + /** * @param {import("./types.js").WorldZooEntry} zoo * @param {() => number} rng @@ -288,6 +303,40 @@ export function tickBotZoos(state, nowUnix, dt) { const PLAYER_AUTO_MIN_INTERVAL = 10; const PLAYER_AUTO_MAX_INTERVAL = 28; +/** @param {string[]} choices + * @param {import("./types.js").GameState} state + * @param {{ level: number, maxLevel: number, costFn: (n: number) => number, key: string, st: number }} opts + * @returns {void} + */ +function maybePushUpgradeChoice(choices, state, opts) { + const { level, maxLevel, costFn, key, st } = opts; + if (level < maxLevel && state.coins >= costFn(level) * st) choices.push(key); +} + +/** @param {import("./types.js").GameState} state + * @param {{ spendThreshold: number }} params + * @returns {string[]} + */ +function getPlayerUpgradeChoices(state, params) { + const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels(); + const st = params.spendThreshold; + const choices = []; + maybePushUpgradeChoice(choices, state, { level: state.plotLevel ?? 1, maxLevel: plotMax, costFn: getPlotUpgradeCost, key: "plot", st }); + maybePushUpgradeChoice(choices, state, { level: state.conveyorLevel ?? 1, maxLevel: skillMax, costFn: getConveyorUpgradeCost, key: "skill", st }); + maybePushUpgradeChoice(choices, state, { level: state.truckLevel ?? 1, maxLevel: truckMax, costFn: getTruckUpgradeCost, key: "truck", st }); + return choices; +} + +/** @param {import("./types.js").GameState} state + * @param {string} choice + * @returns {void} + */ +function applyPlayerUpgradeChoice(state, choice) { + if (choice === "plot") tryUpgradePlot(state); + else if (choice === "skill") tryUpgrade(state); + else if (choice === "truck") tryUpgradeTruck(state); +} + /** * Apply one upgrade (plot, skill, truck) for player auto mode. * @param {import("./types.js").GameState} state @@ -296,24 +345,10 @@ const PLAYER_AUTO_MAX_INTERVAL = 28; * @returns {void} */ function playerAutoDoOneUpgrade(state, params, rng) { - const { spendThreshold, upgradeChance } = params; - const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels(); - const plotCost = getPlotUpgradeCost(state.plotLevel ?? 1); - const skillCost = getConveyorUpgradeCost(state.conveyorLevel ?? 1); - const truckCost = getTruckUpgradeCost(state.truckLevel ?? 1); - 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 choices = getPlayerUpgradeChoices(state, params); + if (choices.length === 0 || rng() >= params.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); + applyPlayerUpgradeChoice(state, choice); } /** diff --git a/web/js/config.js b/web/js/config.js index 550f473..e3c721a 100644 --- a/web/js/config.js +++ b/web/js/config.js @@ -91,6 +91,11 @@ export const GameConfig = { BuildCost: 280, BaseUpgradeCost: 280, UpgradeGrowth: 1.55, + /** Opening hour (0-24). No new visitors outside [OpenHour, CloseHour). */ + OpenHour: 8, + CloseHour: 20, + /** Max new visitors per real second at entry (spec: 1 visiteur/s). */ + MaxEntryPerSecond: 1, }, /** Nourriture: 7 niveaux, 5 animaux/unité. */ @@ -136,6 +141,13 @@ export const GameConfig = { BaseChance: 0.06, }, + /** Death cause "seuls": animal dies if no same species in radius for too long. */ + Animal: { + MinSameSpeciesInRadius: 1, + RadiusCells: 5, + MaxSecondsAlone: 300, + }, + Events: [], Visitor: { @@ -146,35 +158,20 @@ export const GameConfig = { 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, }, @@ -183,17 +180,13 @@ export const GameConfig = { 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, }, @@ -209,6 +202,16 @@ export const GameConfig = { PhaseShift: 0, }, + /** 4 seasons: spring, summer, autumn, winter. Each season lasts DaysPerSeason game days. */ + Season: { + DaysPerSeason: 7, + TemperatureModifier: { spring: 0, summer: 10, autumn: -2, winter: -15 }, + VisitorMultiplier: { spring: 1, summer: 1.5, autumn: 0.8, winter: 0.6 }, + ReproductionBonus: { spring: 0.2, summer: 0, autumn: 0, winter: -0.5 }, + /** Billeterie: summer +20% ticket price, winter -10%. */ + TicketPriceMultiplier: { spring: 1, summer: 1.2, autumn: 1, winter: 0.9 }, + }, + Weather: { ChangeIntervalSeconds: 45, RainChance: 0.25, @@ -228,9 +231,7 @@ export const GameConfig = { /** 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 index 25ebb9f..32d574a 100644 --- a/web/js/conveyor.js +++ b/web/js/conveyor.js @@ -52,6 +52,22 @@ export function getPlayerZooWeights(state) { return w; } +/** + * @param {import("./types.js").GameState} state + * @param {object} zoo + * @param {string} eggType + * @returns {{ skillLevel: number, weight: number } | null} + */ +function getZooSkillAndWeightForEgg(state, zoo, eggType) { + const skillLevel = zoo.id === "player" ? getSkillLevel(state) : getZooSkillLevel(zoo); + const eggDef = LootTables.EggTypes[eggType]; + const minLevel = eggDef ? eggDef.minConveyorLevel : 1; + if (skillLevel < minLevel) return null; + const playerWeights = zoo.id === "player" ? getPlayerZooWeights(state) : (zoo.animalWeights ?? {}); + const weight = playerWeights[eggType] ?? 0; + return weight > 0 ? { skillLevel, weight } : null; +} + /** * Zoos that can offer this egg type (skill level allows it and zoo has weight for it). * @param {import("./types.js").GameState} state @@ -60,17 +76,10 @@ export function getPlayerZooWeights(state) { */ 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 }); - } + const info = getZooSkillAndWeightForEgg(state, zoo, eggType); + if (info) entries.push({ id: zoo.id, weight: info.weight }); } if (entries.length === 0) entries.push({ id: "player", weight: 1 }); return entries; diff --git a/web/js/food.js b/web/js/food.js index a1caa7c..be96ad9 100644 --- a/web/js/food.js +++ b/web/js/food.js @@ -8,6 +8,8 @@ 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"; +import { getSkillLevel } from "./conveyor.js"; +import { getCurrentSeason, getSeasonTemperatureModifier } from "./seasons.js"; /** * Total food capacity = sum over food cells of (level × AnimalsPerUnit). @@ -40,6 +42,21 @@ export function getOriginAnimalCount(state) { return n; } +/** @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {Array<{ key: string, cell: import("./types.js").AnimalCell, lastFed: number }>} + */ +function collectOriginAnimalsByLastFed(state, nowUnix) { + const originAnimals = []; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) { + originAnimals.push({ key, cell, lastFed: cell.lastFedAt ?? cell.placedAt ?? nowUnix }); + } + } + originAnimals.sort((a, b) => a.lastFed - b.lastFed); + return originAnimals; +} + /** * 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). @@ -49,16 +66,7 @@ export function getOriginAnimalCount(state) { 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); - + const originAnimals = collectOriginAnimalsByLastFed(state, nowUnix); let fed = 0; for (const { key, cell } of originAnimals) { if (fed >= capacity) break; @@ -105,30 +113,46 @@ export function getFeedingRate(state, _nowUnix) { /** * 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. + * research level too low for animal rarity, 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) { +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {number} + */ +function applyAnimalBlockDeaths(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; } + return blocksToRemove.length; +} +/** + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + * @returns {number} + */ +function applyBabyAndReceptionDeaths(state, nowUnix) { + const maxMatureNotPlaced = GameConfig.Nursery?.MaxSecondsMatureNotPlaced ?? 90; + const maxReadyNotPlaced = GameConfig.Reception?.MaxSecondsReadyNotPlaced ?? 90; 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; + return babiesRemoved + receptionRemoved; +} + +export function checkDeathCauses(state, nowUnix) { + const n1 = applyAnimalBlockDeaths(state, nowUnix); + const n2 = applyBabyAndReceptionDeaths(state, nowUnix); + const total = n1 + n2; + if (total > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + total; } /** @@ -136,7 +160,7 @@ export function checkDeathCauses(state, nowUnix) { * @returns {Array<{ ox: number, oy: number }>} */ function collectAnimalDeathBlocks(opts) { - const { grid, cells, nowUnix, maxVisit, maxFood } = opts; + const { state, 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)) { @@ -144,7 +168,7 @@ function collectAnimalDeathBlocks(opts) { } else { const def = LootTables.Animals[cell.id]; if (def !== null && def !== undefined) { - const entry = maybeDeathBlock({ key, cell, grid, nowUnix, maxVisit, maxFood, def }); + const entry = maybeDeathBlock({ state, key, cell, grid, nowUnix, maxVisit, maxFood, def }); if (entry) blocksToRemove.push(entry); } } @@ -152,26 +176,120 @@ function collectAnimalDeathBlocks(opts) { 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; +/** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts + * @returns {boolean} + */ +function isBlockTempAndBiomeOk(opts) { + const { key, grid, state, def } = opts; const m = key.match(/^(\d+)_(\d+)$/); - if (!m) return null; + if (!m) return true; const ox = Number(m[1]); const oy = Number(m[2]); const cellBiome = getDisplayBiome(ox, oy, grid); - const cellTemp = getDisplayTemperature(ox, oy, grid); + const baseTemp = getDisplayTemperature(ox, oy, grid); + const seasonMod = getSeasonTemperatureModifier(getCurrentSeason(state)); + const cellTemp = baseTemp + seasonMod; const idealTemp = def.idealTemperature ?? 18; const tolerance = def.temperatureTolerance ?? 5; const tempOk = Math.abs(cellTemp - idealTemp) <= tolerance; const biomeOk = isAnimalAllowedOnBiome(def.biome, cellBiome); + return tempOk && biomeOk; +} + +/** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts + * @returns {boolean} + */ +function isBlockVisitAndFedOk(opts) { + const { cell, nowUnix, maxVisit, maxFood } = opts; + const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix; + const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix; const visitedOk = nowUnix - lastVisited < maxVisit; const fedOk = nowUnix - lastFed < maxFood; - if (!visitedOk || !fedOk || !tempOk || !biomeOk) return { ox, oy }; + return visitedOk && fedOk; +} + +/** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts + * @returns {boolean} + */ +function isBlockEnvironmentOk(opts) { + const { key } = opts; + const m = key.match(/^(\d+)_(\d+)$/); + if (!m) return true; + return isBlockVisitAndFedOk(opts) && isBlockTempAndBiomeOk(opts); +} + +function maybeDeathBlock(opts) { + const { state, key, cell, def } = opts; + const skillLevel = getSkillLevel(state); + const rarityLevel = def.rarityLevel ?? 1; + if (rarityLevel > skillLevel) return getBlockOrigin(opts); + if (!isBlockEnvironmentOk(opts)) return getBlockOrigin(opts); + const aloneOk = checkNotAlone(state, { originKey: key, originCell: cell, nowUnix: opts.nowUnix }); + if (!aloneOk) return getBlockOrigin(opts); return null; } +/** + * @param {import("./types.js").GameState} state + * @param {string} originKey + * @param {import("./types.js").AnimalCell} originCell + * @param {number} radius + * @returns {number} + */ +function countSameSpeciesInRadius(state, originKey, originCell, radius) { + const m = originKey.match(/^(\d+)_(\d+)$/); + if (!m) return 0; + const ox = Number(m[1]); + const oy = Number(m[2]); + const cells = state.grid.cells; + let count = 0; + for (const [k, c] of Object.entries(cells)) { + if (countSameSpeciesCell({ k, c, originCell, ox, oy, radius })) count += 1; + } + return count; +} + +/** + * False if animal should die from solitude (no same species in radius for long enough). + * @param {import("./types.js").GameState} state + * @param {{ originKey: string, originCell: import("./types.js").AnimalCell, nowUnix: number }} opts + * @returns {boolean} + */ +function checkNotAlone(state, opts) { + const { originKey, originCell, nowUnix } = opts; + const cfg = GameConfig.Animal; + if (!cfg || cfg.MinSameSpeciesInRadius === null || cfg.MinSameSpeciesInRadius === undefined || cfg.MinSameSpeciesInRadius <= 0) return true; + const maxAlone = cfg.MaxSecondsAlone ?? 300; + const radius = cfg.RadiusCells ?? 5; + const placedAt = originCell.placedAt ?? nowUnix; + if (nowUnix - placedAt < maxAlone) return true; + const minSame = cfg.MinSameSpeciesInRadius ?? 1; + return countSameSpeciesInRadius(state, originKey, originCell, radius) >= minSame; +} + +/** + * @param {{ k: string, c: import("./types.js").Cell, originCell: import("./types.js").AnimalCell, ox: number, oy: number, radius: number }} opts + * @returns {boolean} + */ +function countSameSpeciesCell(opts) { + const { k, c, originCell, ox, oy, radius } = opts; + if (c === null || c === undefined || c.kind !== "animal" || c.id !== originCell.id) return false; + if (!isOriginCell(k, c)) return false; + const km = k.match(/^(\d+)_(\d+)$/); + if (!km) return false; + const kx = Number(km[1]); + const ky = Number(km[2]); + if (kx === ox && ky === oy) return false; + const manhattan = Math.abs(kx - ox) + Math.abs(ky - oy); + return manhattan <= radius; +} + +function getBlockOrigin(opts) { + const m = opts.key.match(/^(\d+)_(\d+)$/); + if (!m) return null; + return { ox: Number(m[1]), oy: Number(m[2]) }; +} + /** * @param {import("./types.js").GameState} state * @param {number} nowUnix diff --git a/web/js/game-loop.js b/web/js/game-loop.js index 0a6fe00..69d52ec 100644 --- a/web/js/game-loop.js +++ b/web/js/game-loop.js @@ -14,6 +14,24 @@ import { tickFeeding, checkDeathCauses, getFeedingRate } from "./food.js"; import { tickReproduction, getReproductionScore } from "./reproduction.js"; import { tickVisitorIncidents } from "./visitor-incidents.js"; import { tickSaleListings } from "./trade.js"; +import { getCurrentSeason } from "./seasons.js"; + +/** + * @param {import("./types.js").GameState} state + * @param {number} pointsPerLevelPerSecond + * @param {number} dt + * @returns {number} + */ +function sumResearchPointsFromCells(state, pointsPerLevelPerSecond, dt) { + 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; + } + } + return total; +} /** * Add research points from all research cells. PointsPerTickPerLevel * level per second. @@ -24,17 +42,8 @@ import { tickSaleListings } from "./trade.js"; 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; - } + const total = sumResearchPointsFromCells(state, cfg.PointsPerTickPerLevel, dt); + if (total > 0) state.researchPoints = (state.researchPoints ?? 0) + total; } /** @@ -94,8 +103,13 @@ export function startGameLoop(getState, onUpdate, saveStateFn) { lastNpcTruckAt = nowMs; } tickLaboratory(state, nowUnix); - const { hatched, questEarned } = doOneTick(state, nowUnix, nowMs, dt); - if (questEarned > 0) playSound("quest"); + const { hatched, questEarned } = doOneTick(state, nowUnix, nowMs, dt); + const newSeason = getCurrentSeason(state); + if (state.lastSeason !== undefined && state.lastSeason !== newSeason) { + state.seasonChangeMessage = newSeason; + } + state.lastSeason = newSeason; + if (questEarned > 0) playSound("quest"); onUpdate(state, { lastHatched: hatched }); saveAccum += dt; diff --git a/web/js/hatching.js b/web/js/hatching.js index d009c3a..3fd674b 100644 --- a/web/js/hatching.js +++ b/web/js/hatching.js @@ -46,6 +46,44 @@ function buildAnimalCell(animalId, mutationId, nowUnix, dimensions = {}) { }; } +/** + * @param {import("./types.js").GameState} state + * @param {string} key + * @param {number} nowUnix + * @returns {import("./types.js").EggCell | null} + */ +function getEggCellIfReady(state, key, nowUnix) { + const cell = state.grid.cells[key]; + if (cell === null || cell === undefined || cell.kind !== "egg") return null; + if (nowUnix < cell.hatchAt) return null; + return cell; +} + +/** + * @param {{ state: import("./types.js").GameState, cell: import("./types.js").EggCell, x: number, y: number, nowUnix: number, eventModifiers: { mutationBonus: number } }} opts + * @returns {{ animalData: import("./types.js").AnimalCell, w: number, h: number } | null} + */ +function getHatchAnimalData(opts) { + const { state, cell, x, y, nowUnix, eventModifiers } = opts; + const eggDef = LootTables.EggTypes[cell.eggType]; + if (eggDef === null || eggDef === undefined) return null; + const cellBiome = getCellBiome(state.grid.width, state.grid.height, x, y); + const loot = lootForBiome(cellBiome, eggDef.loot); + if (loot.length === 0) return null; + const rng = createSeededRng(cell.seed); + const pickedAnimalId = pickId(rng, loot); + const animalDef = LootTables.Animals[pickedAnimalId]; + if (animalDef === null || animalDef === undefined) return null; + 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 animalData = buildAnimalCell(pickedAnimalId, mutationId, nowUnix, { cellsWide: w, cellsHigh: h }); + return { animalData, w, h }; +} + /** * @param {import("./types.js").GameState} state * @param {{ x: number, y: number, nowUnix: number, eventModifiers: { incomeMultiplier: number, mutationBonus: number } }} opts @@ -54,35 +92,15 @@ function buildAnimalCell(animalId, mutationId, nowUnix, dimensions = {}) { 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 cell = getEggCellIfReady(state, key, nowUnix); + if (cell === null) 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 }); + const hatchData = getHatchAnimalData({ state, cell, x, y, nowUnix, eventModifiers }); + if (hatchData === null) return false; + const { animalData, w, h } = hatchData; + const [canPlace] = 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; } diff --git a/web/js/income-attractivity.js b/web/js/income-attractivity.js new file mode 100644 index 0000000..0b89a7c --- /dev/null +++ b/web/js/income-attractivity.js @@ -0,0 +1,36 @@ +import { LootTables } from "./loot-tables.js"; +import { isOriginCell } from "./grid-utils.js"; +import { getTotalAnimalValue } from "./income-value.js"; +import { getOriginAnimalCount } from "./food.js"; + +/** + * Attractivity from value, species count, rarity and fill rate (before penalties). + * @param {import("./types.js").GameState} state + * @returns {{ valueNorm: number, speciesNorm: number, rarityNorm: number, fillNorm: number }} + */ +export function getAttractivityBase(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; + return { + valueNorm: value * 0.001, + speciesNorm: speciesCount * 2, + rarityNorm: avgRarity * 0.5, + fillNorm: fillRate * 10, + }; +} diff --git a/web/js/income-value.js b/web/js/income-value.js new file mode 100644 index 0000000..2246ee6 --- /dev/null +++ b/web/js/income-value.js @@ -0,0 +1,30 @@ +import { LootTables } from "./loot-tables.js"; +import { getIncomeMultiplier } from "./mutation-rules.js"; +import { getSellValue } from "./economy.js"; +import { isOriginCell } from "./grid-utils.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} + */ +export function getTotalAnimalValue(state) { + let total = 0; + for (const [key, cell] of Object.entries(state.grid.cells)) { + if (cell.kind !== "animal" || !isOriginCell(key, cell)) { + // skip + } 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; +} diff --git a/web/js/income.js b/web/js/income.js index 68e0047..e591cb4 100644 --- a/web/js/income.js +++ b/web/js/income.js @@ -1,35 +1,25 @@ import { LootTables } from "./loot-tables.js"; import { getIncomeMultiplier } from "./mutation-rules.js"; -import { getLevelMultiplier, getSellValue } from "./economy.js"; +import { getLevelMultiplier } from "./economy.js"; import { GameConfig } from "./config.js"; import { getPrestigeIncomeMultiplier } from "./prestige.js"; import { isOriginCell } from "./grid-utils.js"; -import { getOriginAnimalCount } from "./food.js"; +import { getCurrentSeason, getSeasonVisitorMultiplier, getSeasonTicketPriceMultiplier } from "./seasons.js"; +import { getTotalAnimalValue } from "./income-value.js"; +import { getAttractivityBase } from "./income-attractivity.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 + * Visitor demand multiplier by time of day (spec visiteur: 08h-10h faible, 10h-16h fort, 16h-18h décroissant, >18h nul). + * @param {number} timeOfDay 0..24 * @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; +function getVisitorDemandHourMultiplier(timeOfDay) { + const t = timeOfDay % 24; + if (t < 8 || t >= 20) return 0; + if (t >= 8 && t < 10) return 0.5; + if (t >= 10 && t < 16) return 1; + if (t >= 16 && t < 18) return 0.7; + return 0.3; } /** @@ -95,22 +85,41 @@ function getStagnationMultiplier(state, nowUnix) { } /** - * Stay duration multiplier from boutiques and animal diversity (visitors stay longer). + * Shop bonus component of stay multiplier (souvenir shops). * @param {import("./types.js").GameState} state * @returns {number} */ -function getStayMultiplier(state) { +function getStayMultiplierShopBonus(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); } } + return shopBonus; +} + +/** + * Diversity bonus component (species count). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getStayMultiplierDiversityBonus(state) { 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 speciesSet.size * (GameConfig.Visitor.StayMultiplierPerSpecies ?? 0.02); +} + +/** + * Stay duration multiplier from boutiques and animal diversity (visitors stay longer). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getStayMultiplier(state) { + const shopBonus = getStayMultiplierShopBonus(state); + const diversityBonus = getStayMultiplierDiversityBonus(state); return Math.max(0.5, 1 + shopBonus + diversityBonus); } @@ -144,69 +153,155 @@ function getVisitorDemand(state, nowUnix) { demand *= 1 + cityAttraction; demand *= 1 + animalValue * animalValueScale; demand *= getStagnationMultiplier(state, nowUnix); + const seasonMult = getSeasonVisitorMultiplier(getCurrentSeason(state)); + demand *= seasonMult; + const hourMult = getVisitorDemandHourMultiplier(state.timeOfDay ?? 6); + demand *= hourMult; return Math.max(0, Math.floor(demand)); } +/** + * Remove visitors who exceeded stay duration. + * @param {import("./types.js").GameState} state + * @param {number} nowUnix + */ +function filterExpiredVisitors(state, nowUnix) { + const stayDuration = getStayDurationSeconds(state); + state.visitorArrivals = (state.visitorArrivals ?? []).filter( + (v) => nowUnix < v.arrivedAt + stayDuration + ); +} + +/** + * Whether we are within opening hours for new entries. + * @param {import("./types.js").GameState} state + * @returns {boolean} + */ +function isVisitorOpeningHours(state) { + const timeOfDay = state.timeOfDay ?? 6; + const openHour = GameConfig.Billeterie?.OpenHour ?? 8; + const closeHour = GameConfig.Billeterie?.CloseHour ?? 20; + return timeOfDay >= openHour && timeOfDay < closeHour; +} + /** * Update visitor entities: remove those who exceeded stay duration, add new arrivals up to min(cap, demand). + * New arrivals only during opening hours (OpenHour–CloseHour). Max MaxEntryPerSecond new visitors per second. * @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 - ); + filterExpiredVisitors(state, nowUnix); + if (!isVisitorOpeningHours(state)) return; 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++) { + const maxToAdd = target - current; + if (maxToAdd <= 0) return; + const maxPerSecond = GameConfig.Billeterie?.MaxEntryPerSecond ?? 1; + const secondsPerTick = GameConfig.IncomeTickMs / 1000; + const maxThisTick = Math.min(maxToAdd, Math.ceil(maxPerSecond * secondsPerTick)); + for (let i = 0; i < maxThisTick; i++) { state.visitorArrivals.push({ arrivedAt: nowUnix }); } } +/** + * Raw visitor count from animals and plot when no billeterie (for fallback). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getVisitorCountFallback(state) { + 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; + return Math.max(0, Math.floor(animalCount * visitorsPerAnimal + plotBonus)); +} + +/** + * Visitor count capped by billeterie. + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getVisitorCountCapped(state) { + const arrivals = state.visitorArrivals ?? []; + let visitorCount = arrivals.length; + if (visitorCount === 0 && getBilleterieCapacity(state) === 0) { + visitorCount = getVisitorCountFallback(state); + } + const billeterieCap = getBilleterieCapacity(state); + if (billeterieCap > 0 && visitorCount > billeterieCap) visitorCount = billeterieCap; + return visitorCount; +} + +/** + * Luxury shop multiplier component for souvenir bonus (>= 1). + * @returns {number} + */ +function getLuxuryShopMultiplier() { + const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0; + const luxuryShopMult = GameConfig.Visitor.LuxuryShopMultiplier ?? 1; + if (luxuryChance > 0 && luxuryShopMult > 1) { + return 1 + luxuryChance * (luxuryShopMult - 1); + } + return 1; +} + +/** + * Souvenir shop bonus multiplier (>= 1). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getSouvenirBonus(state) { + let shopCount = 0; + for (const cell of Object.values(state.grid.cells)) { + if (cell && cell.kind === "souvenirShop") shopCount += (cell.level ?? 1); + } + if (shopCount === 0) return 1; + const bonusPerShop = GameConfig.Visitor.SouvenirShopBonusPerShop ?? 0.2; + return (1 + shopCount * bonusPerShop) * getLuxuryShopMultiplier(); +} + +/** + * Luxury entry multiplier (>= 1). + * @returns {number} + */ +function getLuxuryEntryMultiplier() { + const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0; + const luxuryEntryMult = GameConfig.Visitor.LuxuryEntryMultiplier ?? 1; + if (luxuryChance > 0 && luxuryEntryMult > 1) { + return 1 + luxuryChance * (luxuryEntryMult - 1); + } + return 1; +} + +/** + * Payment per visitor (base × souvenir × luxury × season). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +function getPaymentPerVisitor(state) { + let paymentPerVisitor = GameConfig.Visitor.BasePaymentPerVisitor; + paymentPerVisitor *= getSouvenirBonus(state); + paymentPerVisitor *= getLuxuryEntryMultiplier(); + const ticketSeasonMult = getSeasonTicketPriceMultiplier(getCurrentSeason(state)); + paymentPerVisitor *= ticketSeasonMult; + return paymentPerVisitor; +} + /** * 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); - } + const visitorCount = getVisitorCountCapped(state); + const paymentPerVisitor = getPaymentPerVisitor(state); return { visitorCount, paymentPerVisitor }; } @@ -220,28 +315,7 @@ export function getVisitorCount(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; + const { valueNorm, speciesNorm, rarityNorm, fillNorm } = getAttractivityBase(state); let score = valueNorm + speciesNorm + rarityNorm + fillNorm; const deathPenalty = GameConfig.Visitor?.AttractivityDeathPenalty ?? 0.5; const birthBonus = GameConfig.Visitor?.AttractivityBirthBonus ?? 0.2; diff --git a/web/js/main.js b/web/js/main.js index 739651f..f176a33 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -19,53 +19,61 @@ function setMyZooId(id) { myZooId = id; } +async function runBootNoBase(rootEl) { + rootEl.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(); + }); + }); + rootEl.innerHTML = ""; +} + +async function runBootWithBase(rootEl) { + rootEl.innerHTML = "

Chargement…

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

Construis un zoo

Erreur de connexion au serveur.

"; + const errP = rootEl.querySelector(".boot-err"); + if (errP && e && e.message) errP.textContent = e.message; + await new Promise((resolve) => { + document.getElementById("boot-retry").addEventListener("click", () => resolve()); + }); + } + } + rootEl.innerHTML = ""; +} + (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 = ""; + await runBootNoBase(root); 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 = ""; + await runBootWithBase(root); } if (state) { diff --git a/web/js/placement.js b/web/js/placement.js index 4f071be..320a949 100644 --- a/web/js/placement.js +++ b/web/js/placement.js @@ -12,18 +12,17 @@ import { 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)]. + * Origin and dimensions for an animal cell (possibly from originKey). Returns null if not animal. * @param {import("./types.js").GameState} state - * @param {number} x - * @param {number} y - * @returns {string[]} + * @param {import("./types.js").Cell} cell + * @param {string} key + * @param {{ x: number, y: number }} pos + * @returns {{ ox: number, oy: number, w: number, h: number } | null} */ -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; +function getAnimalBlockOrigin(state, cell, key, pos) { + if (cell === null || cell === undefined || cell.kind !== "animal") return null; + let ox = pos.x; + let oy = pos.y; let w = cell.cellsWide ?? 1; let h = cell.cellsHigh ?? 1; if (cell.originKey !== null && cell.originKey !== undefined) { @@ -38,7 +37,41 @@ export function getBlockKeysFromCell(state, x, y) { } } } - return getBlockKeys(ox, oy, w, h); + return { ox, oy, w, h }; +} + +/** + * 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]; + const origin = getAnimalBlockOrigin(state, cell, key, { x, y }); + if (origin === null) return [key]; + return getBlockKeys(origin.ox, origin.oy, origin.w, origin.h); +} + +/** + * Build set of cell keys to exclude when placing (block at excludeOriginKey). + * @param {import("./types.js").GameState} state + * @param {string} [excludeOriginKey] + * @returns {Set} + */ +function buildExcludeSet(state, excludeOriginKey) { + const excludeSet = new Set(); + if (excludeOriginKey === null || excludeOriginKey === undefined) return excludeSet; + 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)); + } + return excludeSet; } /** @@ -49,16 +82,7 @@ export function getBlockKeysFromCell(state, x, y) { */ 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)); - } - } + const excludeSet = buildExcludeSet(state, excludeOriginKey); for (let dy = 0; dy < h; dy++) { for (let dx = 0; dx < w; dx++) { const nx = originX + dx; @@ -124,6 +148,25 @@ export function placeEgg(state, opts) { return [true, undefined]; } +/** + * Move an animal block from (ox,oy) to (toX, toY). Caller ensures source is animal and keys differ. + * @param {import("./types.js").GameState} state + * @param {{ blockKeys: string[], ox: number, oy: number, toX: number, toY: number, source: import("./types.js").AnimalCell }} opts + * @returns {[boolean, string?]} + */ +function moveAnimalBlock(state, opts) { + const { blockKeys, ox, oy, toX, toY, source } = opts; + const w = source.cellsWide ?? 1; + const h = source.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: cellKey(toX, toY), cellsWide: w, cellsHigh: h }; + for (const k of blockKeys) delete state.grid.cells[k]; + fillAnimalBlock(state, toX, toY, animalData); + 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 @@ -139,29 +182,9 @@ export function moveCell(state, opts) { 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 origin = getAnimalBlockOrigin(state, source, fromKey, { x: fromX, y: fromY }); + if (origin === null) return [false, "NoSource"]; + return moveAnimalBlock(state, { blockKeys, ox: origin.ox, oy: origin.oy, toX, toY, source }); } const [ok, reason] = canPlace(state, toX, toY); if (!ok) return [false, reason]; diff --git a/web/js/quests.js b/web/js/quests.js index 2b77eb8..6a7b062 100644 --- a/web/js/quests.js +++ b/web/js/quests.js @@ -18,15 +18,6 @@ function daySeed(dateKey) { 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[]} @@ -54,7 +45,9 @@ export function generateDailyQuests(state) { return state.quests; } -/** @param {import("./types.js").GameState} state */ +/** @param {import("./types.js").GameState} state + * @returns {{ eggsPlaced: number, animalsSold: number, conveyorUpgrades: number, plotUpgrades: number, coinsEarned: number }} + */ function getQuestProgress(state) { const s = state.stats ?? { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, coinsEarned: 0 }; return { @@ -66,6 +59,14 @@ function getQuestProgress(state) { }; } +const QUEST_PROGRESS_KEYS = { + questPlaceEggs: "eggsPlaced", + questSellAnimals: "animalsSold", + questUpgradeConveyor: "conveyorUpgrades", + questUpgradePlot: "plotUpgrades", + questEarnCoins: "coinsEarned", +}; + /** * @param {import("./types.js").GameState} state * @returns {number} coins awarded from completed quests this tick @@ -75,16 +76,10 @@ export function tickQuests(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) { + if (!q.done) { + const progressKey = QUEST_PROGRESS_KEYS[q.descriptionKey]; + const current = progressKey !== undefined ? (progress[progressKey] ?? 0) : null; + if (current !== null) { q.current = Math.min(current, q.target); if (q.current >= q.target) { q.done = true; diff --git a/web/js/reproduction.js b/web/js/reproduction.js index d757c62..f05186e 100644 --- a/web/js/reproduction.js +++ b/web/js/reproduction.js @@ -9,6 +9,19 @@ import { cellKey, isOriginCell } from "./grid-utils.js"; import { getBlockKeysFromCell } from "./placement.js"; import { getDisplayBiome, getDisplayTemperature } from "./biome-rules.js"; import { addPendingBaby } from "./zoo.js"; +import { getCurrentSeason, getSeasonReproductionBonus, getSeasonTemperatureModifier } from "./seasons.js"; + +/** + * Reproduction season bonus. In winter, cold-adapted biomes (Mountain) are exempt from the -50% malus (spec: sauf espèces adaptées). + * @param {string} season + * @param {import("./loot-tables.js").LootTables["Animals"][string]} def + * @returns {number} + */ +function getEffectiveReproductionSeasonBonus(season, def) { + const base = getSeasonReproductionBonus(season); + if (season === "winter" && base < 0 && def?.biome === "Mountain") return 0; + return base; +} /** * Zoo reproduction score (stub for phase 7). Higher = shorter delay until baby. @@ -81,16 +94,17 @@ function blocksAreAdjacent(state, keyA, keyB) { } /** - * All eligible reproduction pairs: same animalId, at least one fromOtherZoo, adjacent. - * Returns unique pairs with keyA < keyB lexicographically. + * Collect origin animal entries (key, animalId, fromOtherZoo) from grid. * @param {import("./types.js").GameState} state - * @returns {Array<{ keyA: string, keyB: string, animalId: string }>} + * @returns {Array<{ key: string, animalId: string, fromOtherZoo: boolean }>} */ -export function findReproductionPairs(state) { +function collectOriginAnimals(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)) { + if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) { + // skip + } else { const def = LootTables.Animals[cell.id]; if (def !== null && def !== undefined) { origins.push({ @@ -101,6 +115,16 @@ export function findReproductionPairs(state) { } } } + return origins; +} + +/** + * Form unique pairs from origins (same animalId, at least one fromOtherZoo, adjacent). + * @param {import("./types.js").GameState} state + * @param {Array<{ key: string, animalId: string, fromOtherZoo: boolean }>} origins + * @returns {Array<{ keyA: string, keyB: string, animalId: string }>} + */ +function formReproductionPairs(state, origins) { const pairs = []; for (let i = 0; i < origins.length; i++) { for (let j = i + 1; j < origins.length; j++) { @@ -116,6 +140,17 @@ export function findReproductionPairs(state) { return pairs; } +/** + * 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 origins = collectOriginAnimals(state); + return formReproductionPairs(state, origins); +} + /** * Unique pair key for deduplication. * @param {string} keyA @@ -196,13 +231,16 @@ function addNewPairsToTimers(state, nowUnix, timers, existingSet) { const m1 = keyA.match(/^(\d+)_(\d+)$/); const m2 = keyB.match(/^(\d+)_(\d+)$/); if (m1 && m2) { + const season = getCurrentSeason(state); + const seasonTempMod = getSeasonTemperatureModifier(season); 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 temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid) + seasonTempMod; + const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid) + seasonTempMod; 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 seasonBonus = getEffectiveReproductionSeasonBonus(season, def); + const factor = Math.max(0.2, score * biomeFactor * tempFactor * (1 + seasonBonus)); const delay = Math.max(5, baseSeconds / factor); timers.push({ keyA, keyB, animalId, dueAt: nowUnix + Math.floor(delay) }); existingSet.add(pk); diff --git a/web/js/seasons.js b/web/js/seasons.js new file mode 100644 index 0000000..d88f46d --- /dev/null +++ b/web/js/seasons.js @@ -0,0 +1,79 @@ +/** + * Season cycle: 4 seasons from game day. Used for temperature, visitors, reproduction, billeterie. + * Refs: docs/specs/inventaire_saisons.md, temperature.md, visiteur.md, reproduction.md, billeterie.md. + */ + +import { GameConfig } from "./config.js"; + +/** @typedef {"spring"|"summer"|"autumn"|"winter"} SeasonId */ + +const SEASON_ORDER = /** @type {SeasonId[]} */ (["spring", "summer", "autumn", "winter"]); + +/** + * Current season from state game day. + * @param {import("./types.js").GameState} state + * @returns {SeasonId} + */ +export function getCurrentSeason(state) { + const cfg = GameConfig.Season; + if (!cfg || !cfg.DaysPerSeason) return "spring"; + const gameDay = state.gameDayTotal ?? 0; + const seasonIndex = Math.floor(gameDay / cfg.DaysPerSeason) % 4; + return SEASON_ORDER[seasonIndex] ?? "spring"; +} + +/** + * Day index within current season (0..DaysPerSeason-1). + * @param {import("./types.js").GameState} state + * @returns {number} + */ +export function getSeasonDay(state) { + const cfg = GameConfig.Season; + if (!cfg || !cfg.DaysPerSeason) return 0; + const gameDay = state.gameDayTotal ?? 0; + return gameDay % cfg.DaysPerSeason; +} + +/** + * Temperature modifier for display/calculations (°C). From temperature.md. + * @param {SeasonId} season + * @returns {number} + */ +export function getSeasonTemperatureModifier(season) { + const cfg = GameConfig.Season?.TemperatureModifier; + if (!cfg) return 0; + return cfg[season] ?? 0; +} + +/** + * Visitor demand multiplier. From visiteur.md (Impact Saisons). + * @param {SeasonId} season + * @returns {number} + */ +export function getSeasonVisitorMultiplier(season) { + const cfg = GameConfig.Season?.VisitorMultiplier; + if (!cfg) return 1; + return cfg[season] ?? 1; +} + +/** + * Reproduction chance bonus (e.g. spring +0.2, winter -0.5). From reproduction.md. + * @param {SeasonId} season + * @returns {number} + */ +export function getSeasonReproductionBonus(season) { + const cfg = GameConfig.Season?.ReproductionBonus; + if (!cfg) return 0; + return cfg[season] ?? 0; +} + +/** + * Billeterie ticket price multiplier (summer +20%, winter -10%). From billeterie.md. + * @param {SeasonId} season + * @returns {number} + */ +export function getSeasonTicketPriceMultiplier(season) { + const cfg = GameConfig.Season?.TicketPriceMultiplier; + if (!cfg) return 1; + return cfg[season] ?? 1; +} diff --git a/web/js/state.js b/web/js/state.js index 8a3fa5e..d776a98 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -54,14 +54,19 @@ function buildDefaultCells() { return buildDefaultRow1Cells(); } +function getDefaultStats() { + return { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 }; +} + function buildStatePayload(width, height, worldZoos, cells) { + const grid = { width, height, cells }; return { version: GameConfig.StateVersion, coins: 200, conveyorLevel: 1, plotLevel: 1, truckLevel: 1, - grid: { width, height, cells }, + grid, pendingEggTokens: [], nextTokenId: 1, conveyorOffers: [], @@ -73,15 +78,14 @@ function buildStatePayload(width, height, worldZoos, cells) { laboratoryOffer: null, prestigeLevel: 0, timeOfDay: 6, + gameDayTotal: 0, + lastSeason: "spring", 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, + stats: getDefaultStats(), + mapZoom: 1, mapPanX: 0, mapPanY: 0, worldMapLevel: 1, autoMode: false, autoModeProfile: "balanced", researchPoints: 0, @@ -145,16 +149,22 @@ function applyLoadStateWorldZoos(data) { 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")); + applyWorldZoosWeightsAndBots(data); } - if (data.worldZoos === null || data.worldZoos === undefined) data.worldZoos = [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }]; + if (data.worldZoos === null || data.worldZoos === undefined) { + data.worldZoos = [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }]; + } +} + +function applyWorldZoosWeightsAndBots(data) { + const keys = getColorNames(); + data.worldZoos = data.worldZoos.map((z) => ({ + ...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")); } /** Set data[key] to defaultVal when data[key] is null or undefined. defaultVal may be a function (called for value). */ @@ -170,6 +180,8 @@ const LOAD_STATE_SCALAR_DEFAULTS = [ ["nextTokenId", 1], ["prestigeLevel", 0], ["timeOfDay", 6], + ["gameDayTotal", 0], + ["lastSeason", "spring"], ["weather", "sun"], ["lastWeatherChangeAt", 0], ["quests", []], @@ -211,15 +223,24 @@ function applyLoadStateLegacyCells(data) { if (c12 && (c12.kind === "plotUpgrade" || c12.kind === "worldMapUpgrade")) delete data.grid.cells["1_2"]; } +function normalizeOneAnimalCell(cell, now) { + if (cell.kind !== "animal") return; + if (cell.id && !LootTables.Animals[cell.id]) cell.id = "c0_r0"; + if (cell.lastVisitedAt === null || cell.lastVisitedAt === undefined) cell.lastVisitedAt = now; + if (cell.lastFedAt === null || cell.lastFedAt === undefined) cell.lastFedAt = cell.placedAt ?? now; +} + +function normalizeOneEggCell(cell) { + if (cell.kind === "egg" && cell.eggType && !LootTables.EggTypes[cell.eggType]) cell.eggType = "Color_1"; +} + 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"; + if (cell.kind === "animal") normalizeOneAnimalCell(cell, now); + if (cell.kind === "egg") normalizeOneEggCell(cell); } } } diff --git a/web/js/texts-fr.js b/web/js/texts-fr.js index e4ecb80..57a49f5 100644 --- a/web/js/texts-fr.js +++ b/web/js/texts-fr.js @@ -98,6 +98,8 @@ export const weatherLabel = { sun: "Ensoleillé", cloudy: "Nuageux", rain: "Plui 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 seasonLabel = { spring: "Printemps", summer: "Été", autumn: "Automne", winter: "Hiver" }; +export const seasonChangeToast = "C'est le %s !"; export const visitorsLabel = "Visiteurs"; export const musicLabel = "Musique"; export const incidentLabel = { diff --git a/web/js/time-weather.js b/web/js/time-weather.js index dd79aa6..41b7e9d 100644 --- a/web/js/time-weather.js +++ b/web/js/time-weather.js @@ -7,7 +7,12 @@ import { GameConfig } from "./config.js"; 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; + if (phase >= 24) { + state.timeOfDay = phase - 24; + state.gameDayTotal = (state.gameDayTotal ?? 0) + 1; + } else { + state.timeOfDay = phase; + } } /** diff --git a/web/js/trade.js b/web/js/trade.js index 9b58130..24fd542 100644 --- a/web/js/trade.js +++ b/web/js/trade.js @@ -91,7 +91,7 @@ export function addReceptionAnimalToSale(state, receptionCellKey) { /** * Remove expired sale listings. If listing was a baby (isBaby), increment deathCountRecent (bébé invendu meurt). - * Call from game loop each tick. + * If listing was an adult (isBaby false), also increment deathCountRecent (vente échouée = mort adulte). * @param {import("./types.js").GameState} state * @param {number} nowUnix */ @@ -99,24 +99,29 @@ export function tickSaleListings(state, nowUnix) { const listings = state.saleListings ?? []; const kept = []; let babyDeaths = 0; + let adultDeaths = 0; for (const listing of listings) { if (nowUnix < listing.endAt) { kept.push(listing); } else if (listing.isBaby) { babyDeaths += 1; + } else { + adultDeaths += 1; } } state.saleListings = kept; - if (babyDeaths > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babyDeaths; + const totalDeaths = babyDeaths + adultDeaths; + if (totalDeaths > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + totalDeaths; } /** + * Compute sell result for animal at (x,y). Returns [false, reason] or [true, { blockKeys, sellValue }]. * @param {import("./types.js").GameState} state * @param {number} x * @param {number} y - * @returns {[boolean, number | string]} + * @returns {[false, string] | [true, { blockKeys: string[], sellValue: number }]} */ -export function sellAnimalToNpc(state, x, y) { +function getSellAnimalResult(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"]; @@ -133,6 +138,19 @@ export function sellAnimalToNpc(state, x, y) { mutationMultiplier, animalDef.sellFactor ); + return [true, { blockKeys, sellValue }]; +} + +/** + * @param {import("./types.js").GameState} state + * @param {number} x + * @param {number} y + * @returns {[boolean, number | string]} + */ +export function sellAnimalToNpc(state, x, y) { + const result = getSellAnimalResult(state, x, y); + if (result[0] === false) return [false, result[1]]; + const { blockKeys, sellValue } = result[1]; for (const k of blockKeys) delete state.grid.cells[k]; state.coins += sellValue; state.lastEvolutionAt = Math.floor(Date.now() / 1000); diff --git a/web/js/types.js b/web/js/types.js index e651e73..73a2833 100644 --- a/web/js/types.js +++ b/web/js/types.js @@ -43,6 +43,9 @@ * laboratoryOffer?: { eggType: string, price: number, endAt: number } | null, * prestigeLevel?: number, * timeOfDay?: number, + * gameDayTotal?: number, + * lastSeason?: string, + * seasonChangeMessage?: string, * weather?: string, * lastWeatherChangeAt?: number, * quests?: Quest[], @@ -72,7 +75,6 @@ * feedingRate?: number, * reproductionScore?: number, * attractivityScore?: number, - * attractivityScore?: number, * reproductionTimers?: Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>, * visitorArrivals?: VisitorEntry[], * attractivityBonusFromIncidents?: number, diff --git a/web/js/ui-grid-cells.js b/web/js/ui-grid-cells.js new file mode 100644 index 0000000..b9680f4 --- /dev/null +++ b/web/js/ui-grid-cells.js @@ -0,0 +1,273 @@ +import { + getNurseryBuildCost, + getSouvenirShopBuildCost, + getResearchBuildCost, + getBilleterieBuildCost, + getFoodBuildCost, + getReceptionBuildCost, + getBiomeChangeColorBuildCost, + getBiomeChangeTempBuildCost, + getSchoolUpgradeCost, + getReceptionUpgradeCost, + getNurseryUpgradeCost, + getSouvenirShopUpgradeCost, + getResearchUpgradeCost, + getBilleterieUpgradeCost, + getFoodUpgradeCost, + getBiomeChangeColorUpgradeCost, + getBiomeChangeTempUpgradeCost, +} from "./economy.js"; +import { getAnimalVisualState } from "./animal-visual-state.js"; +import { GameConfig } from "./config.js"; +import { eggTypeLabel, animalLabel } from "./texts-fr.js"; + +const EGG_EMOJI = "🥚"; + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, animalEmoji: Record }} ctx + * @param {HTMLElement} divEl + * @param {import("./types.js").ReceptionCell} receptionCell + * @param {string} cellKey + */ +export function fillReceptionCell(ctx, divEl, receptionCell, cellKey) { + const { state, animalEmoji } = ctx; + divEl.classList.add("reception"); + const level = receptionCell.level ?? 1; + const maxLevel = GameConfig.Reception?.MaxLevel ?? 7; + const canUpgrade = level < maxLevel && state.coins >= getReceptionUpgradeCost(level); + if (canUpgrade) divEl.classList.add("can-upgrade"); + const recAnimal = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === cellKey); + 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…"; + divEl.classList.add("cell-draggable"); + divEl.draggable = isReady; + const arrow = canUpgrade ? '' : ""; + divEl.innerHTML = `${emoji}${label}${arrow}`; + if (isReady) divEl.dataset.receptionCellKey = cellKey; + } else { + const arrow = canUpgrade ? '' : ""; + divEl.innerHTML = `📥Accueil ${level}${arrow}`; + } +} + +/** + * @param {{ state: import("./types.js").GameState, animalEmoji: Record }} ctx + * @param {HTMLElement} divEl + * @param {import("./types.js").NurseryCell} nurseryCell + * @param {string} cellKey + */ +export function fillNurseryCell(ctx, divEl, nurseryCell, cellKey) { + const { state, animalEmoji } = ctx; + divEl.classList.add("nursery"); + const nurseryLevel = nurseryCell.level ?? 1; + const nurseryMax = GameConfig.Nursery?.MaxLevel ?? 7; + const canUpgradeNursery = nurseryLevel < nurseryMax && state.coins >= getNurseryUpgradeCost(nurseryLevel); + if (canUpgradeNursery) divEl.classList.add("can-upgrade"); + const pendingBaby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === cellKey); + const token = nurseryCell.tokenId !== null && nurseryCell.tokenId !== undefined + ? state.pendingEggTokens.find((tok) => tok.tokenId === nurseryCell.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é…"; + divEl.classList.add("cell-draggable"); + divEl.draggable = isMature; + divEl.innerHTML = `${emoji}${label}`; + if (isMature) divEl.dataset.nurseryCellKey = cellKey; + } else if (token) { + divEl.classList.add("cell-draggable"); + divEl.draggable = true; + const label = eggTypeLabel[token.eggType] ?? token.eggType; + divEl.innerHTML = `${EGG_EMOJI}${label}`; + divEl.dataset.tokenId = String(nurseryCell.tokenId); + } else { + const arrow = canUpgradeNursery ? '' : ""; + divEl.innerHTML = `🐣Nurserie ${nurseryLevel}${arrow}`; + } +} + +/** + * @param {{ state: import("./types.js").GameState, animalEmoji: Record }} ctx + * @param {HTMLElement} divEl + * @param {import("./types.js").AnimalCell} animalCell + * @param {string} cellKey + */ +export function fillAnimalCell(ctx, divEl, animalCell, cellKey) { + const { state, animalEmoji } = ctx; + divEl.classList.add("animal", "cell-draggable"); + divEl.draggable = true; + const w = animalCell.cellsWide ?? 1; + const h = animalCell.cellsHigh ?? 1; + const isMulti = w > 1 || h > 1; + const isOrigin = animalCell.originKey === null || animalCell.originKey === undefined || animalCell.originKey === cellKey; + const originKey = isOrigin ? cellKey : (animalCell.originKey ?? cellKey); + const originCell = isOrigin ? animalCell : state.grid.cells[originKey]; + if (originCell && originCell.kind === "animal") { + const visual = getAnimalVisualState(originCell, state, state.grid, originKey); + if (visual.cold) divEl.classList.add("animal-cold"); + if (visual.hot) divEl.classList.add("animal-hot"); + if (visual.hungry) divEl.classList.add("animal-hungry"); + if (visual.sick) divEl.classList.add("animal-sick"); + if (visual.happy) divEl.classList.add("animal-happy"); + } + if (isMulti) divEl.classList.add("multi-cell"); + if (isMulti && isOrigin) divEl.classList.add("multi-cell-origin"); + const emoji = animalEmoji[animalCell.id] ?? "🐾"; + const label = animalLabel[animalCell.id] ?? animalCell.id; + divEl.innerHTML = `${emoji}${label}`; +} + +/** + * @param {{ state: import("./types.js").GameState }} ctx + * @param {HTMLElement} div + * @param {number} x + * @param {number} y + */ +export function fillEmptyCell(ctx, div, x, y) { + const { state } = ctx; + const emptyCellChoice = ctx.emptyCellChoice.current; + 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 = ""; + } +} + +function fillSchoolCell(ctx, div, cell) { + const { state } = ctx; + 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}`; +} + +function fillSouvenirShopCell(ctx, div, cell) { + const { state } = ctx; + 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}`; +} + +function fillResearchCell(ctx, div, cell) { + const { state } = ctx; + 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}`; +} + +function fillBilleterieCell(ctx, div, cell) { + const { state } = ctx; + 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}`; +} + +function fillFoodCell(ctx, div, cell) { + const { state } = ctx; + 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}`; +} + +function fillBiomeChangeColorCell(ctx, div, cell) { + const { state } = ctx; + 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}`; +} + +function fillBiomeChangeTempCell(ctx, div, cell) { + const { state } = ctx; + 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}`; +} + +function fillEggCell(ctx, div, cell) { + div.classList.add("egg", "cell-draggable"); + div.draggable = true; + const label = eggTypeLabel[cell.eggType] ?? cell.eggType; + div.innerHTML = `${EGG_EMOJI}${label}`; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { current: { x: number, y: number } | null }, selectedTokenId: { current: number | null }, lastActionWasDrop: { current: boolean }, clampSelection: () => void, animalEmoji: Record }} ctx + * @param {HTMLElement} div + * @param {{ cell: import("./types.js").GridCell | null | undefined, key: string, x: number, y: number }} cellInfo + */ +export function fillCellContent(ctx, div, cellInfo) { + const { cell, key, x, y } = cellInfo; + if (cell === null || cell === undefined) { + fillEmptyCell(ctx, div, x, y); + return; + } + const filler = FILL_BY_KIND[cell.kind]; + if (filler) { + filler(ctx, div, cell, key); + return; + } + fillAnimalCell(ctx, div, cell, key); +} + +const FILL_BY_KIND = { + school: (ctx, div, cell) => fillSchoolCell(ctx, div, cell), + nursery: (ctx, div, cell, key) => fillNurseryCell(ctx, div, cell, key), + souvenirShop: (ctx, div, cell) => fillSouvenirShopCell(ctx, div, cell), + research: (ctx, div, cell) => fillResearchCell(ctx, div, cell), + billeterie: (ctx, div, cell) => fillBilleterieCell(ctx, div, cell), + food: (ctx, div, cell) => fillFoodCell(ctx, div, cell), + reception: (ctx, div, cell, key) => fillReceptionCell(ctx, div, cell, key), + biomeChangeColor: (ctx, div, cell) => fillBiomeChangeColorCell(ctx, div, cell), + biomeChangeTemp: (ctx, div, cell) => fillBiomeChangeTempCell(ctx, div, cell), + egg: (ctx, div, cell) => fillEggCell(ctx, div, cell), +}; + +export { EGG_EMOJI }; diff --git a/web/js/ui-grid-drag.js b/web/js/ui-grid-drag.js new file mode 100644 index 0000000..6502d8c --- /dev/null +++ b/web/js/ui-grid-drag.js @@ -0,0 +1,136 @@ +/** + * Drag-related helpers for grid cells: set DataTransfer for dragstart, attach drag listeners, handle drop. + */ +import { tryBuyEgg, tryPlaceEgg, placeMatureBabyOnCell, placeReceptionAnimalOnCell } from "./zoo.js"; +import { moveCell } from "./placement.js"; +import { t, errorMessage } from "./texts-fr.js"; + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDrop: { current: boolean } }} ctx + * @param {DragEvent} e + * @param {{ x: number, y: number, empty: boolean }} target + * @returns {boolean} + */ +export function handleCellDropNurseryReceptionToken(ctx, e, target) { + const { state, setState, setError, playSound } = ctx; + const { x: toX, y: toY, empty } = target; + const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key"); + if (nurseryCellKey && empty) { + 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"); } + ctx.lastActionWasDrop.current = true; + setState(); + return true; + } + const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key"); + if (receptionCellKey && empty) { + 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"); } + ctx.lastActionWasDrop.current = true; + setState(); + return true; + } + const tokenIdStr = e.dataTransfer.getData("application/x-builazoo-tokenid"); + if (tokenIdStr && empty) { + 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"); } + ctx.lastActionWasDrop.current = true; + setState(); + } + return true; + } + return false; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDrop: { current: boolean } }} ctx + * @param {DragEvent} e + * @param {{ x: number, y: number, empty: boolean }} target + * @returns {boolean} + */ +export function handleCellDropEggTypeOrMove(ctx, e, target) { + const { state, setState, setError, playSound } = ctx; + const { x: toX, y: toY, empty } = target; + const eggTypeFromConveyor = e.dataTransfer.getData("application/x-builazoo-eggtype"); + if (eggTypeFromConveyor && empty) { + 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"); } + } + ctx.lastActionWasDrop.current = true; + setState(); + return true; + } + const raw = e.dataTransfer.getData("text/plain"); + if (!raw || !/^\d+_\d+$/.test(raw)) return true; + 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(""); + ctx.lastActionWasDrop.current = true; + setState(); + return true; +} + +/** + * @param {HTMLElement} div + * @param {import("./types.js").GridCell | null | undefined} cell + * @param {{ x: number, y: number }} pos + * @param {DataTransfer} dt + */ +export function setCellDragData(div, cell, pos, dt) { + const { x, y } = pos; + let dragX = x; + let dragY = y; + if (div.dataset.nurseryCellKey) { + dt.setData("application/x-builazoo-nursery-cell-key", div.dataset.nurseryCellKey); + dt.effectAllowed = "move"; + } else if (div.dataset.receptionCellKey) { + dt.setData("application/x-builazoo-reception-cell-key", div.dataset.receptionCellKey); + dt.effectAllowed = "move"; + } else if (cell && 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) dt.setData("text/plain", `${dragX}_${dragY}`); + if (cell && cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) { + dt.setData("application/x-builazoo-tokenid", String(cell.tokenId)); + } + dt.effectAllowed = dt.effectAllowed || "move"; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement }} ctx + * @param {{ div: HTMLElement, cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts + */ +export function attachDragListeners(ctx, opts) { + const { div, cell } = opts; + const { gridEl } = ctx; + const isDraggable = 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) + ); + if (!isDraggable) return; + div.addEventListener("dragstart", (e) => { + setCellDragData(div, cell, { x: opts.x, y: opts.y }, e.dataTransfer); + 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); + div.addEventListener("dragend", () => { ghost.remove(); }, { once: true }); + }); + div.addEventListener("dragend", () => { + div.classList.remove("dragging"); + gridEl.querySelectorAll(".cell").forEach((c) => c.classList.remove("dragover")); + }); +} diff --git a/web/js/ui-grid-handlers.js b/web/js/ui-grid-handlers.js new file mode 100644 index 0000000..a8a66f8 --- /dev/null +++ b/web/js/ui-grid-handlers.js @@ -0,0 +1,257 @@ +import { tryPlaceEgg, getNurseryCellKeysOrdered } from "./zoo.js"; +import { tryUpgradeSchool } from "./conveyor.js"; +import { + tryBuildNursery, + tryBuildSouvenirShop, + tryBuildResearch, + tryBuildBilleterie, + tryBuildFood, + tryBuildReception, + tryBuildBiomeChangeColor, + tryBuildBiomeChangeTemp, + tryUpgradeNursery, + tryUpgradeSouvenirShop, + tryUpgradeResearch, + tryUpgradeBilleterie, + tryUpgradeFood, + tryUpgradeReception, + tryUpgradeBiomeChangeColor, + tryUpgradeBiomeChangeTemp, +} from "./placement.js"; +import { t, errorMessage } from "./texts-fr.js"; +import { attachDragListeners, handleCellDropNurseryReceptionToken, handleCellDropEggTypeOrMove } from "./ui-grid-drag.js"; + +const BUILD_FNS = [ + ["nursery", tryBuildNursery], + ["shop", tryBuildSouvenirShop], + ["research", tryBuildResearch], + ["billeterie", tryBuildBilleterie], + ["food", tryBuildFood], + ["reception", tryBuildReception], + ["biomeColor", tryBuildBiomeChangeColor], + ["biomeTemp", tryBuildBiomeChangeTemp], +]; + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, selected: { x: number, y: number } }} ctx + * @param {string} choice + * @param {number} x + * @param {number} y + */ +export function handleCellClickChoice(ctx, choice, x, y) { + const { state, setError } = ctx; + for (const [name, fn] of BUILD_FNS) { + if (choice === name) { + const [ok, reason] = fn(state, x, y); + if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); + else setError(""); + ctx.emptyCellChoice.current = null; + return; + } + } +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} ctx + * @param {import("./types.js").GridCell} cell + * @param {number} x + * @param {number} y + * @returns {boolean} + */ +export function handleCellClickUpgradeShops(ctx, cell, x, y) { + const { state, setState, setError, playSound } = ctx; + if (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 true; + } + if (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 true; + } + if (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 true; + } + if (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 true; + } + return false; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} ctx + * @param {import("./types.js").GridCell} cell + * @param {{ x: number, y: number, key: string }} pos + * @returns {boolean} + */ +export function handleCellClickUpgradeRest(ctx, cell, pos) { + const { x, y, key } = pos; + const { state, setState, setError, playSound } = ctx; + if (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 true; + } + if (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 true; + } + if (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 true; + } + if (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 true; + } + return false; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, selectedTokenId: number | null, emptyCellChoice: { x: number, y: number } | null }} ctx + * @param {boolean} empty + * @param {number} x + * @param {number} y + */ +export function handleCellClickPlaceOrSelect(ctx, empty, x, y) { + const { state, setState, setError, playSound } = ctx; + 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 = ctx.selectedTokenId.current ?? firstTokenId; + if (empty && tokenId !== null && tokenId !== undefined) { + const nowUnix = Math.floor(Date.now() / 1000); + const [ok, reason] = tryPlaceEgg(state, { tokenId, x, y, nowUnix }); + if (ok) { ctx.selectedTokenId.current = null; setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } + setState(); + return; + } + if (empty) ctx.emptyCellChoice.current = { x, y }; + setState(); +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement }} ctx + * @param {{ div: HTMLElement, cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts + */ +export function attachCellListeners(ctx, opts) { + const { div, cell, x, y, key } = opts; + attachDragListeners(ctx, opts); + 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"); + handleCellDrop(ctx, e, { toX: Number(div.dataset.x), toY: Number(div.dataset.y), cell }); + }); + div.addEventListener("click", (e) => handleCellClick(ctx, { e, cell, x, y, key })); + div.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); div.click(); } + }); +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement }} ctx + * @param {DragEvent} e + * @param {{ toX: number, toY: number, cell: import("./types.js").GridCell | null | undefined }} target + */ +function handleCellDrop(ctx, e, target) { + const { toX, toY, cell } = target; + const empty = cell === null || cell === undefined; + if (handleCellDropNurseryReceptionToken(ctx, e, { x: toX, y: toY, empty })) return; + handleCellDropEggTypeOrMove(ctx, e, { x: toX, y: toY, empty }); +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, selected: { x: number, y: number } }} ctx + * @param {{ e: Event, cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts + */ +function handleCellClick(ctx, opts) { + const { e, cell, x, y, key } = opts; + const { state, setState, setError, playSound } = ctx; + if (ctx.lastActionWasDrop.current) { + ctx.lastActionWasDrop.current = false; + return; + } + const choiceBtn = e.target.closest(".cell-choice-btn"); + const empty = cell === null || cell === undefined; + if (choiceBtn && empty && ctx.emptyCellChoice.current && ctx.emptyCellChoice.current.x === x && ctx.emptyCellChoice.current.y === y) { + handleCellClickChoice(ctx, choiceBtn.dataset.choice, x, y); + setState(); + return; + } + if (cell !== null && cell !== undefined && cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) { + ctx.selectedTokenId.current = 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 (handleCellClickUpgrade(ctx, { cell, x, y, key })) return; + ctx.selected.x = x; + ctx.selected.y = y; + ctx.clampSelection(); + if (empty && ctx.emptyCellChoice.current && ctx.emptyCellChoice.current.x === x && ctx.emptyCellChoice.current.y === y) { + ctx.emptyCellChoice.current = null; + setState(); + return; + } + handleCellClickPlaceOrSelect(ctx, empty, x, y); +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} ctx + * @param {{ cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts + * @returns {boolean} + */ +function handleCellClickUpgrade(ctx, opts) { + const { cell, x, y, key } = opts; + if (cell === null || cell === undefined) return false; + if (handleCellClickUpgradeShops(ctx, cell, x, y)) return true; + if (handleCellClickUpgradeRest(ctx, cell, { x, y, key })) return true; + return false; +} + +export { handleCellDrop, handleCellClick }; diff --git a/web/js/ui-grid.js b/web/js/ui-grid.js new file mode 100644 index 0000000..10f4d79 --- /dev/null +++ b/web/js/ui-grid.js @@ -0,0 +1,47 @@ +import { getDisplayBiome, getDisplayTemperature, getTemperatureBand } from "./biome-rules.js"; +import { fillCellContent } from "./ui-grid-cells.js"; +import { attachCellListeners } from "./ui-grid-handlers.js"; + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, animalEmoji: Record }} ctx + * @param {number} x + * @param {number} y + * @returns {HTMLElement} + */ +function buildOneCell(ctx, x, y) { + const { state, getHatched, selected } = ctx; + 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"); + fillCellContent(ctx, div, { cell, key, x, y }); + attachCellListeners(ctx, { div, cell, x, y, key }); + return div; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, animalEmoji: Record }} ctx + */ +export function renderGrid(ctx) { + const { state, gridEl } = ctx; + 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++) { + gridEl.appendChild(buildOneCell(ctx, x, y)); + } + } +} diff --git a/web/js/ui-helpers.js b/web/js/ui-helpers.js new file mode 100644 index 0000000..4aa1b7b --- /dev/null +++ b/web/js/ui-helpers.js @@ -0,0 +1,36 @@ +/** + * @param {string} labelContent + * @param {string} tooltipText + * @returns {HTMLElement} + */ +export 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 + * @returns {void} + */ +export 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); +} diff --git a/web/js/ui-render-dom-drops.js b/web/js/ui-render-dom-drops.js new file mode 100644 index 0000000..8bf0eb2 --- /dev/null +++ b/web/js/ui-render-dom-drops.js @@ -0,0 +1,166 @@ +import { tryBuyEgg, tryBuyBaby, tryBuyAnimal } from "./zoo.js"; +import { pickSaleTargetZoo } from "./conveyor.js"; +import { sellAnimalToNpc, addMatureBabyToSale, addReceptionAnimalToSale } from "./trade.js"; +import { playSound } from "./audio.js"; +import { t, errorMessage } from "./texts-fr.js"; +import { getApiBase, createSale } from "./api-client.js"; + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {boolean} + */ +function handleTruckDropBaby(e, setup) { + const babyOffer = e.dataTransfer.getData("application/x-builazoo-baby-offer"); + if (!babyOffer) return false; + const [animalId, priceStr] = babyOffer.split(":"); + const price = Number(priceStr) || 80; + const [ok, result] = tryBuyBaby(setup.state, animalId, price); + if (!ok) setup.setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); + else setup.setError(""); + playSound(ok ? "buy" : "error"); + setup.setState(); + return true; +} + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {boolean} + */ +function handleTruckDropAnimal(e, setup) { + const animalOffer = e.dataTransfer.getData("application/x-builazoo-animal-offer"); + if (!animalOffer) return false; + const [animalId, priceStr] = animalOffer.split(":"); + const price = Number(priceStr) || 120; + const [ok, result] = tryBuyAnimal(setup.state, animalId, price); + if (!ok) setup.setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); + else setup.setError(""); + playSound(ok ? "buy" : "error"); + setup.setState(); + return true; +} + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {boolean} + */ +function handleTruckDropEgg(e, setup) { + const eggType = e.dataTransfer.getData("application/x-builazoo-eggtype"); + if (!eggType) return false; + const toZooId = e.dataTransfer.getData("application/x-builazoo-offer-zooid") || "player"; + const [ok, result] = tryBuyEgg(setup.state, eggType); + if (!ok) { + setup.setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setup.setError(""); + playSound("buy"); + setup.state.eggPurchaseTruck = { eggType, fromZooId: "player", toZooId, startAt: Date.now() }; + } + setup.setState(); + return true; +} + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {void} + */ +export function handleWorldMapTruckDrop(e, setup) { + e.preventDefault(); + const el = e.currentTarget; + if (el && el instanceof HTMLElement) el.classList.remove("dragover"); + if (handleTruckDropBaby(e, setup)) return; + if (handleTruckDropAnimal(e, setup)) return; + handleTruckDropEgg(e, setup); +} + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean } }} setup + * @returns {boolean} + */ +function applyNurseryDrop(e, setup) { + const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key"); + if (!nurseryCellKey) return false; + const [ok, result] = addMatureBabyToSale(setup.state, nurseryCellKey); + if (!ok) { + setup.setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setup.setError(""); + playSound("sell"); + const listing = setup.state.saleListings[setup.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; setup.setState(); }).catch(() => {}); + } + } + setup.lastActionWasDropRef.current = true; + setup.setState(); + return true; +} + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean } }} setup + * @returns {boolean} + */ +function applyReceptionDrop(e, setup) { + const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key"); + if (!receptionCellKey) return false; + const [ok, result] = addReceptionAnimalToSale(setup.state, receptionCellKey); + if (!ok) { + setup.setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setup.setError(""); + playSound("sell"); + const listing = setup.state.saleListings[setup.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; setup.setState(); }).catch(() => {}); + } + } + setup.lastActionWasDropRef.current = true; + setup.setState(); + return true; +} + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean } }} setup + * @returns {boolean} + */ +function applyGridCellSell(e, setup) { + const raw = e.dataTransfer.getData("text/plain"); + if (!raw || !/^\d+_\d+$/.test(raw)) return false; + const [sx, sy] = raw.split("_").map(Number); + const [ok, result] = sellAnimalToNpc(setup.state, sx, sy); + if (!ok) { + setup.setError(String(t.sellFailed).replace("%s", errorMessage[result] ?? result)); + playSound("error"); + } else { + setup.setError(""); + playSound("sell"); + setup.state.truckSale = { toZooId: pickSaleTargetZoo(setup.state), startAt: Date.now() }; + } + setup.lastActionWasDropRef.current = true; + setup.setState(); + return true; +} + +/** + * @param {DragEvent} e + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean }, sellZoneJustDroppedRef: { current: boolean } }} setup + * @returns {void} + */ +export function handleSellZoneDrop(e, setup) { + e.preventDefault(); + const el = e.currentTarget; + if (el && el instanceof HTMLElement) el.classList.remove("dragover"); + setup.sellZoneJustDroppedRef.current = true; + if (applyNurseryDrop(e, setup)) return; + if (applyReceptionDrop(e, setup)) return; + applyGridCellSell(e, setup); +} diff --git a/web/js/ui-render-dom-panels.js b/web/js/ui-render-dom-panels.js new file mode 100644 index 0000000..c04f96c --- /dev/null +++ b/web/js/ui-render-dom-panels.js @@ -0,0 +1,224 @@ +import { tryUpgradeWorldMap, tryUpgradePlot } from "./zoo.js"; +import { tryUpgradeTruck } from "./conveyor.js"; +import { getTruckUpgradeCost } from "./economy.js"; +import { playSound } from "./audio.js"; +import { t, errorMessage, sellZoneTitle, sellZoneShortLabel } from "./texts-fr.js"; +import { GameConfig } from "./config.js"; +import { handleWorldMapTruckDrop, handleSellZoneDrop } from "./ui-render-dom-drops.js"; + +/** + * @param {HTMLElement} panelWorld + * @param {{ state: import("./types.js").GameState }} setup + * @returns {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement }} + */ +function buildWorldMapWrap(panelWorld, setup) { + const { state } = setup; + 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); + return { worldMapEl, worldMapTruckEl, worldMapNpcTrucksEl }; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {HTMLElement} + */ +function buildWorldMapUpgradeZone(setup) { + const { state, setState, setError } = setup; + 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(); + } + }); + return worldMapUpgradeZone; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {HTMLElement} + */ +function buildWorldMapTruckDropZone(setup) { + 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", (ev) => handleWorldMapTruckDrop(ev, setup)); + return worldMapTruckDropZone; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {{ worldMapUpgradeZone: HTMLElement, worldMapCounters: HTMLElement, worldMapTruckDropZone: HTMLElement, worldMapActions: HTMLElement }} + */ +function buildWorldMapActions(setup) { + const worldMapActions = document.createElement("div"); + worldMapActions.className = "world-map-actions"; + const worldMapUpgradeZone = buildWorldMapUpgradeZone(setup); + 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 = buildWorldMapTruckDropZone(setup); + worldMapActions.appendChild(worldMapTruckDropZone); + return { worldMapUpgradeZone, worldMapCounters, worldMapTruckDropZone, worldMapActions }; +} + +/** + * @param {HTMLElement} panelWorld + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} setup + * @returns {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, worldMapUpgradeZone: HTMLElement, worldMapCounters: HTMLElement }} + */ +export function buildWorldMapSection(panelWorld, setup) { + const wrapResult = buildWorldMapWrap(panelWorld, setup); + const actionsResult = buildWorldMapActions(setup); + panelWorld.appendChild(actionsResult.worldMapActions); + return { ...wrapResult, worldMapUpgradeZone: actionsResult.worldMapUpgradeZone, worldMapCounters: actionsResult.worldMapCounters }; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup + * @returns {HTMLElement} + */ +function buildPlotUpgradeZone(setup) { + const { state, setState, setError } = setup; + 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(); + } + }); + return plotUpgradeZone; +} + +/** + * @param {HTMLElement} panelZoo + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDropRef: { current: boolean }, sellZoneJustDroppedRef: { current: boolean } }} setup + * @returns {{ gridEl: HTMLElement, plotUpgradeZone: HTMLElement, sellZone: HTMLElement }} + */ +export function buildGridSection(panelZoo, setup) { + const gridWrap = document.createElement("div"); + gridWrap.className = "grid-wrap"; + const gridEl = document.createElement("div"); + gridEl.className = "grid"; + gridWrap.appendChild(gridEl); + const plotUpgradeZone = buildPlotUpgradeZone(setup); + gridWrap.appendChild(plotUpgradeZone); + const sellZone = document.createElement("div"); + sellZone.className = "sell-zone"; + sellZone.setAttribute("aria-label", sellZoneTitle); + sellZone.title = sellZoneTitle; + sellZone.innerHTML = "🚚" + sellZoneShortLabel + ""; + attachSellZoneListeners(sellZone, setup); + gridWrap.appendChild(sellZone); + const visitorsLayer = document.createElement("div"); + visitorsLayer.className = "visitors-layer"; + visitorsLayer.setAttribute("aria-hidden", "true"); + gridWrap.appendChild(visitorsLayer); + panelZoo.appendChild(gridWrap); + return { gridEl, plotUpgradeZone, sellZone }; +} + +/** + * @param {HTMLElement} sellZone + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDropRef: { current: boolean }, sellZoneJustDroppedRef: { current: boolean } }} setup + * @returns {void} + */ +function attachSellZoneListeners(sellZone, setup) { + 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) => handleSellZoneDrop(e, setup)); + sellZone.addEventListener("click", () => handleSellZoneClick(setup)); +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, sellZoneJustDroppedRef: { current: boolean } }} setup + * @returns {void} + */ +function handleSellZoneClick(setup) { + if (setup.sellZoneJustDroppedRef.current) { + setup.sellZoneJustDroppedRef.current = false; + return; + } + const state = setup.state; + 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) { + setup.setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); + playSound("error"); + } else { + setup.setError(""); + playSound("truckUpgrade"); + } + setup.setState(); +} diff --git a/web/js/ui-render-dom.js b/web/js/ui-render-dom.js new file mode 100644 index 0000000..b4c04a4 --- /dev/null +++ b/web/js/ui-render-dom.js @@ -0,0 +1,274 @@ +import { refreshOffers, getSkillLevel } from "./conveyor.js"; +import { getPlotUpgradeCost, getTruckUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js"; +import { getVisitorCount } from "./income.js"; +import { getTimePhase } from "./time-weather.js"; +import { canPrestige, doPrestige } from "./prestige.js"; +import { playSound, isMusicEnabled } from "./audio.js"; +import { questDescription, timePhaseLabel, weatherLabel, prestigeHint } from "./texts-fr.js"; +import { GameConfig } from "./config.js"; +import { renderWorldMap } from "./ui-world-map.js"; +import { renderGrid } from "./ui-grid.js"; +import { buildGameBar } from "./ui-render-gamebar.js"; +import { buildWorldMapSection, buildGridSection } from "./ui-render-dom-panels.js"; + +/** + * @param {{ errEl: HTMLElement }} _setup + * @returns {{ tabsWrap: HTMLElement, tabContent: HTMLElement, panelZoo: HTMLElement, panelWorld: HTMLElement }} + */ +function createTabsStructure(_setup) { + 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"); + return { tabsWrap, tabContent, panelZoo, panelWorld }; +} + +/** + * @param {Array<{ descriptionKey: string, target: number, current: number, done: boolean }>} quests + * @returns {string} + */ +function formatQuestListHtml(quests) { + return (quests ?? []).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(""); +} + +/** + * @param {object} gameBarResult + * @param {object} setup + * @returns {void} + */ +function updateStatusBody(gameBarResult, setup) { + const { state } = setup; + const { selected } = setup; + const { + statusBarCoins, + statusBarPlot, + statusBarCell, + statusBarSkill, + statusBarVisitors, + statusBarOffers, + statusBarTimeWeather, + musicBtn, + autoModeBtn, + prestigeBtn, + questListEl, + } = gameBarResult; + 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 timePhase = getTimePhase(state.timeOfDay ?? 6); + const weatherVal = weatherLabel[state.weather] ?? state.weather; + statusBarTimeWeather.valueEl.textContent = `${timePhaseLabel[timePhase.phase]} · ${weatherVal}`; + 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); + questListEl.innerHTML = formatQuestListHtml(state.quests); +} + +/** + * @param {object} gameBarResult + * @param {object} setup + * @returns {() => void} + */ +function createUpdateStatus(gameBarResult, setup) { + return function updateStatus() { + updateStatusBody(gameBarResult, setup); + }; +} + +/** + * @param {object} opts + * @returns {() => void} + */ +function createFullRender(opts) { + const { + setup, + sellZone, + plotUpgradeZone, + worldMapUpgradeZone, + worldMapCounters, + worldMapCtx, + gridCtx, + updateStatus, + } = opts; + const { state } = setup; + return function fullRender() { + setup.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"; + updateWorldMapUpgradeAndCounters(worldMapUpgradeZone, worldMapCounters, state); + 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(worldMapCtx); + renderGrid(gridCtx); + }; +} + +/** + * @param {HTMLElement} worldMapUpgradeZone + * @param {HTMLElement} worldMapCounters + * @param {import("./types.js").GameState} state + * @returns {void} + */ +function updateWorldMapUpgradeAndCounters(worldMapUpgradeZone, worldMapCounters, state) { + const mapCfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade; + const mapMaxLevel = mapCfg ? mapCfg.MaxLevel : 5; + const currentMapLevel = state.worldMapLevel ?? 1; + const mapResearchCost = getWorldMapUpgradeResearchCost(currentMapLevel); + const canUpgradeMap = currentMapLevel < mapMaxLevel && (state.researchPoints ?? 0) >= mapResearchCost; + worldMapUpgradeZone.classList.toggle("can-upgrade", canUpgradeMap); + worldMapUpgradeZone.title = currentMapLevel < 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 = currentMapLevel < 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); + } +} + +/** + * @param {{ setup: object, gameBarResult: object, worldMapResult: object, gridResult: object }} opts + * @returns {{ worldMapCtx: object, gridCtx: object }} + */ +function buildFinishContexts(opts) { + const { setup, worldMapResult, gridResult } = opts; + const worldMapCtx = { + worldMapEl: worldMapResult.worldMapEl, + worldMapTruckEl: worldMapResult.worldMapTruckEl, + worldMapNpcTrucksEl: worldMapResult.worldMapNpcTrucksEl, + state: setup.state, + setState: setup.setState, + setError: setup.setError, + playSound, + animalEmoji: setup.animalEmoji, + pendingTokenByEggType: setup.pendingTokenByEggType, + }; + const gridCtx = { + state: setup.state, + setState: setup.setState, + setError: setup.setError, + playSound, + gridEl: gridResult.gridEl, + getHatched: setup.getHatched, + selected: setup.selected, + emptyCellChoice: setup.emptyCellChoiceRef, + selectedTokenId: setup.selectedTokenIdRef, + lastActionWasDrop: setup.lastActionWasDropRef, + clampSelection: setup.clampSelection, + animalEmoji: setup.animalEmoji, + }; + return { worldMapCtx, gridCtx }; +} + +/** + * @param {{ root: HTMLElement, setup: object, gameBarResult: object, worldMapResult: object, gridResult: object, updateStatus: () => void }} opts + * @returns {() => void} + */ +function finishBuildUIDOM(opts) { + const { setup, gameBarResult, worldMapResult, gridResult, updateStatus } = opts; + const { worldMapCtx, gridCtx } = buildFinishContexts(opts); + renderWorldMap(worldMapCtx); + renderGrid(gridCtx); + gameBarResult.prestigeBtn.addEventListener("click", () => { + if (!canPrestige(setup.state)) return; + doPrestige(setup.state); + refreshOffers(setup.state, Math.floor(Date.now() / 1000)); + setup.setError(""); + playSound("upgrade"); + setup.setState(); + }); + const fullRender = createFullRender({ + setup, + sellZone: gridResult.sellZone, + plotUpgradeZone: gridResult.plotUpgradeZone, + worldMapUpgradeZone: worldMapResult.worldMapUpgradeZone, + worldMapCounters: worldMapResult.worldMapCounters, + worldMapCtx, + gridCtx, + updateStatus, + }); + fullRender(); + return fullRender; +} + +/** + * @param {HTMLElement} root + * @param {object} setup + * @returns {() => void} + */ +export function buildUIDOM(root, setup) { + const tabs = createTabsStructure(setup); + const { panelZoo, panelWorld, tabsWrap, tabContent } = tabs; + const gameBarResult = buildGameBar(setup, panelZoo, panelWorld); + const worldMapResult = buildWorldMapSection(panelWorld, setup); + const gridResult = buildGridSection(panelZoo, setup); + tabsWrap.appendChild(setup.errEl); + tabContent.appendChild(panelZoo); + tabContent.appendChild(panelWorld); + tabsWrap.appendChild(tabContent); + gameBarResult.gameBarActions.insertBefore(gameBarResult.viewSwitcherWrap, gameBarResult.gameBarActions.firstChild); + root.appendChild(gameBarResult.gameBar); + root.appendChild(tabsWrap); + const updateStatus = createUpdateStatus(gameBarResult, setup); + return finishBuildUIDOM({ root, setup, gameBarResult, worldMapResult, gridResult, updateStatus }); +} diff --git a/web/js/ui-render-gamebar-picker.js b/web/js/ui-render-gamebar-picker.js new file mode 100644 index 0000000..9510c89 --- /dev/null +++ b/web/js/ui-render-gamebar-picker.js @@ -0,0 +1,95 @@ +import { + autoProfileFamilyLabel, + autoProfileSpecialisationLabel, + autoProfilePickerTitle, + autoProfilePickerFamilyStep, + autoProfilePickerSpecialisationStep, + autoProfileCancel, +} from "./texts-fr.js"; +import { getProfilesByFamily, AUTO_MODE_FAMILY_IDS } from "./auto-mode-profiles.js"; + +/** + * @param {(p: Partial) => void} updateState + * @param {HTMLElement} pickerWrap + * @returns {void} + */ +function buildAutoProfilePickerFamilyStep(updateState, pickerWrap) { + 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); +} + +/** + * @param {string|number} familyId + * @param {(p: Partial) => void} updateState + * @param {HTMLElement} pickerWrap + * @returns {void} + */ +function buildAutoProfilePickerSpecStep(familyId, updateState, pickerWrap) { + 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); +} + +/** + * @param {{ state: import("./types.js").GameState, updateState?: (p: Partial) => void }} setup + * @param {HTMLElement} gameBarActions + * @returns {void} + */ +export function buildAutoProfilePicker(setup, gameBarActions) { + const { state, updateState } = setup; + if (!state.autoProfilePickerOpen || !updateState) return; + 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) { + buildAutoProfilePickerFamilyStep(updateState, pickerWrap); + } else { + buildAutoProfilePickerSpecStep(familyId, updateState, pickerWrap); + } + 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); +} diff --git a/web/js/ui-render-gamebar.js b/web/js/ui-render-gamebar.js new file mode 100644 index 0000000..5092a5c --- /dev/null +++ b/web/js/ui-render-gamebar.js @@ -0,0 +1,271 @@ +import { makeHelpWrap } from "./ui-helpers.js"; +import { setMusicEnabled, isMusicEnabled } from "./audio.js"; +import { + t, + prestigeLabel, + prestigeHint, + visitorsLabel, + musicLabel, + restartButton, + helpRestart, + questsTitle, +} from "./texts-fr.js"; +import { getApiBase, getSales } from "./api-client.js"; +import { buildAutoProfilePicker } from "./ui-render-gamebar-picker.js"; + +/** + * @param {string} iconEmoji + * @param {string} tooltipText + * @param {string} initialValue + * @returns {{ item: HTMLElement, valueEl: HTMLElement }} + */ +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 }; +} + +/** + * @returns {{ gameBar: HTMLElement }} + */ +function buildGameBarTitle() { + 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); + return { gameBar }; +} + +/** + * @param {HTMLElement} gameBar + * @returns {{ statusBarCoins: { item: HTMLElement, valueEl: HTMLElement }, statusBarPlot: object, statusBarCell: object, statusBarSkill: object, statusBarVisitors: object, statusBarOffers: object, statusBarTimeWeather: object }} + */ +function buildStatusBar(gameBar) { + const statusBar = document.createElement("div"); + statusBar.className = "status-bar"; + statusBar.setAttribute("aria-label", "Indicateurs"); + 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); + return { statusBarCoins, statusBarPlot, statusBarCell, statusBarSkill, statusBarVisitors, statusBarOffers, statusBarTimeWeather }; +} + +/** + * @param {HTMLElement} panelZoo + * @param {HTMLElement} panelWorld + * @param {{ state: import("./types.js").GameState, setState: () => void }} setup + * @returns {{ viewSwitcherWrap: HTMLElement, viewToggleBtn: HTMLElement }} + */ +function buildViewSwitcher(panelZoo, panelWorld, setup) { + const { state, setState } = setup; + 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 = "🗺️"; + 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); + viewSwitcherWrap.appendChild(viewToggleBtn); + return { viewSwitcherWrap, viewToggleBtn }; +} + +/** + * @returns {HTMLElement} + */ +function buildMusicBtn() { + 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); + }); + return musicBtn; +} + +/** + * @param {{ state: import("./types.js").GameState, updateState?: (p: Partial) => void }} setup + * @returns {HTMLElement} + */ +function buildAutoModeBtn(setup) { + const { state, updateState } = setup; + 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) { + updateState({ autoMode: false }); + } else if (updateState) { + updateState({ autoProfilePickerOpen: true, autoProfilePickerFamily: undefined }); + } + }); + return autoModeBtn; +} + +/** + * @param {{ onRestart?: () => void }} setup + * @param {HTMLElement} gameBarActions + * @returns {{ prestigeBtn: HTMLElement, restartBtn: HTMLElement }} + */ +function buildPrestigeRestart(setup, gameBarActions) { + const { onRestart } = setup; + 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); + return { prestigeBtn, restartBtn }; +} + +/** + * @param {HTMLElement} gameBarActions + * @returns {{ questListEl: HTMLElement }} + */ +function buildQuestDropdown(gameBarActions) { + 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); + return { questListEl }; +} + +/** + * @param {{ state: import("./types.js").GameState, setState: () => void, updateState?: (p: Partial) => void, onRestart?: () => void }} setup + * @param {HTMLElement} panelZoo + * @param {HTMLElement} panelWorld + * @returns {{ gameBar: HTMLElement, gameBarActions: HTMLElement, statusBarCoins: object, statusBarPlot: object, statusBarCell: object, statusBarSkill: object, statusBarVisitors: object, statusBarOffers: object, statusBarTimeWeather: object, viewSwitcherWrap: HTMLElement, viewToggleBtn: HTMLElement, musicBtn: HTMLElement, autoModeBtn: HTMLElement, prestigeBtn: HTMLElement, restartBtn: HTMLElement, questListEl: HTMLElement }} + */ +export function buildGameBar(setup, panelZoo, panelWorld) { + const { gameBar } = buildGameBarTitle(); + const statusBars = buildStatusBar(gameBar); + const gameBarActions = document.createElement("div"); + gameBarActions.className = "game-bar-actions"; + const viewSwitcher = buildViewSwitcher(panelZoo, panelWorld, setup); + const musicBtn = buildMusicBtn(); + const autoModeBtn = buildAutoModeBtn(setup); + gameBarActions.appendChild(autoModeBtn); + buildAutoProfilePicker(setup, gameBarActions); + gameBarActions.insertBefore(viewSwitcher.viewSwitcherWrap, gameBarActions.firstChild); + const { prestigeBtn, restartBtn } = buildPrestigeRestart(setup, gameBarActions); + const { questListEl } = buildQuestDropdown(gameBarActions); + gameBar.appendChild(gameBarActions); + return { + gameBar, + gameBarActions, + ...statusBars, + viewSwitcherWrap: viewSwitcher.viewSwitcherWrap, + viewToggleBtn: viewSwitcher.viewToggleBtn, + musicBtn, + autoModeBtn, + prestigeBtn, + restartBtn, + questListEl, + }; +} diff --git a/web/js/ui-world-map-cities.js b/web/js/ui-world-map-cities.js new file mode 100644 index 0000000..226d3c9 --- /dev/null +++ b/web/js/ui-world-map-cities.js @@ -0,0 +1,33 @@ +import { GameConfig } from "./config.js"; + +/** + * @param {{ worldMapEl: HTMLElement }} ctx + */ +export function renderCities(ctx) { + 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); + } + ctx.worldMapEl.appendChild(cityEl); + } +} diff --git a/web/js/ui-world-map-sales.js b/web/js/ui-world-map-sales.js new file mode 100644 index 0000000..2d10524 --- /dev/null +++ b/web/js/ui-world-map-sales.js @@ -0,0 +1,162 @@ +import { addPendingBaby, addReceptionAnimal } from "./zoo.js"; +import { acceptSale, rejectSale, deliverSale, placeBid } from "./api-client.js"; +import { + animalLabel, + salesPanelMySales, + salesPanelToRecover, + salesPanelAuctions, + salesBtnAccept, + salesBtnReject, + salesBtnDeliver, + salesBtnBid, + salesPendingValidation, + salesValidationInMinutes, + salesBidInputAriaLabel, + noFreeNursery, + noFreeReception, +} from "./texts-fr.js"; + +/** + * @param {HTMLElement} panel + * @param {{ asSeller?: Array<{ id: string, animal_id: string, is_baby: boolean, initial_price: number, best_bid_amount?: number | null }> }} api + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record }} ctx + */ +export function addSalesPanelSellerSection(panel, api, ctx) { + if (!api.asSeller || api.asSeller.length === 0) return; + const { state, setState, setError, animalEmoji } = ctx; + const sellerTitle = document.createElement("div"); + sellerTitle.className = "sales-panel-title"; + sellerTitle.textContent = salesPanelMySales; + panel.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); + } + panel.appendChild(row); + } +} + +/** + * @param {{ id: string, animal_id: string, is_baby: boolean, status?: string, validated_at?: string }} s + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record }} ctx + * @returns {HTMLElement} + */ +export function createBuyerDeliverRow(s, ctx) { + const { state, setState, setError, animalEmoji } = ctx; + 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); + return row; +} + +/** + * @param {HTMLElement} panel + * @param {{ asBuyerUndelivered?: Array<{ id: string, animal_id: string, is_baby: boolean, status?: string, validated_at?: string }> }} api + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record }} ctx + */ +export function addSalesPanelBuyerSection(panel, api, ctx) { + if (!api.asBuyerUndelivered || api.asBuyerUndelivered.length === 0) return; + const buyerTitle = document.createElement("div"); + buyerTitle.className = "sales-panel-title"; + buyerTitle.textContent = salesPanelToRecover; + panel.appendChild(buyerTitle); + for (const s of api.asBuyerUndelivered) { + panel.appendChild(createBuyerDeliverRow(s, ctx)); + } +} + +/** + * @param {HTMLElement} panel + * @param {{ active?: Array<{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, best_bid_amount?: number | null }> }} api + * @param {string} playerZooId + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record }} ctx + */ +export function addSalesPanelActiveSection(panel, api, playerZooId, ctx) { + if (!api.active || api.active.length === 0) return; + const { state, setState, setError, animalEmoji } = ctx; + const activeTitle = document.createElement("div"); + activeTitle.className = "sales-panel-title"; + activeTitle.textContent = salesPanelAuctions; + panel.appendChild(activeTitle); + for (const s of api.active) { + if (s.seller_zoo_id === playerZooId) { + // skip own listings in active list + } else { + 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); + panel.appendChild(row); + } + } +} diff --git a/web/js/ui-world-map-trucks.js b/web/js/ui-world-map-trucks.js new file mode 100644 index 0000000..6de032d --- /dev/null +++ b/web/js/ui-world-map-trucks.js @@ -0,0 +1,213 @@ +import { tryBuyLabEgg } from "./zoo.js"; +import { GameConfig } from "./config.js"; +import { eggTypeLabel, errorMessage, t } from "./texts-fr.js"; + +const EGG_EMOJI = "🥚"; + +/** + * @param {{ state: import("./types.js").GameState, setError: (s: string) => void, playSound: (s: string) => void, setState: () => void, pendingTokenByEggType: Record }} ctx + * @param {{ eggType: string }} labOffer + * @param {boolean} ok + * @param {{ tokenId?: number } | string} result + */ +function handleLabOfferClick(ctx, labOffer, ok, result) { + if (!ok) { + const msg = errorMessage[result] ?? result; + ctx.setError(String(t.buyFailed).replace("%s", msg)); + ctx.playSound("error"); + ctx.setState(); + return; + } + ctx.setError(""); + ctx.playSound("buy"); + ctx.pendingTokenByEggType[labOffer.eggType] = result.tokenId; + ctx.setState(); +} + +/** + * @param {{ eggType: string, price: number }} labOffer + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, pendingTokenByEggType: Record }} ctx + * @returns {HTMLElement} + */ +function createLabOfferButton(labOffer, ctx) { + const { state } = ctx; + 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); + handleLabOfferClick(ctx, labOffer, ok, result); + }); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.click(); } + }); + return el; +} + +/** + * @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, pendingTokenByEggType: Record }} ctx + */ +export function renderLab(ctx) { + const lab = GameConfig.WorldMap?.Laboratory; + if (!lab) return; + const { state, worldMapEl } = ctx; + 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) { + labSlotEl.appendChild(createLabOfferButton(labOffer, ctx)); + } 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); +} + +/** + * @param {{ worldMapTruckEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx + * @param {Array} zoos + * @param {number} truckMs + * @returns {boolean} true if truck was updated (need setTimeout) + */ +function updateTruckSale(ctx, zoos, truckMs) { + const { worldMapTruckEl, state, setState } = ctx; + const truckSale = state.truckSale; + if (!truckSale || !truckSale.toZooId) return false; + const elapsed = Date.now() - (truckSale.startAt || 0); + if (elapsed >= truckMs) { + delete state.truckSale; + return false; + } + const fromZoo = zoos.find((z) => z.id === "player"); + const toZoo = zoos.find((z) => z.id === truckSale.toZooId); + if (!fromZoo || !toZoo) return false; + 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); + return true; +} + +/** + * @param {{ worldMapTruckEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx + * @param {Array} zoos + * @param {number} truckMs + * @returns {boolean} true if truck was updated (need setTimeout) + */ +function updateEggPurchaseTruck(ctx, zoos, truckMs) { + const { worldMapTruckEl, state, setState } = ctx; + const eggPurchase = state.eggPurchaseTruck; + const truckLevel = state.truckLevel ?? 1; + if (!eggPurchase || !eggPurchase.startAt) return false; + const durationMs = Math.max(1000, (truckMs * 2) / truckLevel); + const elapsed = Date.now() - eggPurchase.startAt; + if (elapsed >= durationMs) { + delete state.eggPurchaseTruck; + worldMapTruckEl.style.display = "none"; + return false; + } + const fromZoo = zoos.find((z) => z.id === eggPurchase.fromZooId); + const toZoo = zoos.find((z) => z.id === eggPurchase.toZooId); + if (!fromZoo || !toZoo) return false; + const progress = elapsed / durationMs; + const leg = progress < 0.5 ? progress * 2 : (progress - 0.5) * 2; + const x = progress < 0.5 + ? fromZoo.x + (toZoo.x - fromZoo.x) * leg + : toZoo.x + (fromZoo.x - toZoo.x) * leg; + const y = progress < 0.5 + ? fromZoo.y + (toZoo.y - fromZoo.y) * leg + : toZoo.y + (fromZoo.y - toZoo.y) * leg; + worldMapTruckEl.style.display = "block"; + worldMapTruckEl.style.left = `${x}%`; + worldMapTruckEl.style.top = `${y}%`; + worldMapTruckEl.textContent = "🚚"; + setTimeout(setState, 50); + return true; +} + +/** + * @param {{ worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx + * @param {Array} zoos + */ +function updatePlayerTruck(ctx, zoos) { + const { worldMapTruckEl } = ctx; + const truckMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500; + if (updateTruckSale(ctx, zoos, truckMs)) return; + if (updateEggPurchaseTruck(ctx, zoos, truckMs)) return; + worldMapTruckEl.style.display = "none"; +} + +/** + * @param {{ worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx + * @param {Array} zoos + * @param {number} truckMs + */ +function renderNpcTrucks(ctx, zoos, truckMs) { + const { worldMapNpcTrucksEl, state } = ctx; + worldMapNpcTrucksEl.innerHTML = ""; + const npcTrucks = state.worldTruckSales ?? []; + for (const truck of npcTrucks) { + const fromZoo = zoos.find((z) => z.id === truck.fromZooId); + const toZoo = zoos.find((z) => z.id === truck.toZooId); + if (fromZoo && toZoo) { + const elapsed = Date.now() - (truck.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); + } + } + } +} + +/** + * @param {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx + * @param {Array} zoos + */ +export function renderTruckAndNpcTrucks(ctx, zoos) { + const truckMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500; + const truckSale = ctx.state.truckSale; + const eggPurchase = ctx.state.eggPurchaseTruck; + updatePlayerTruck(ctx, zoos); + renderNpcTrucks(ctx, zoos, truckMs); + const npcTrucks = ctx.state.worldTruckSales ?? []; + if (npcTrucks.length > 0 || (truckSale && truckSale.toZooId) || (eggPurchase && eggPurchase.startAt)) { + setTimeout(ctx.setState, 50); + } +} diff --git a/web/js/ui-world-map.js b/web/js/ui-world-map.js new file mode 100644 index 0000000..284cdcd --- /dev/null +++ b/web/js/ui-world-map.js @@ -0,0 +1,242 @@ +import { getCellBiome } from "./biome-rules.js"; +import { mapServerListingToClient } from "./api-client.js"; +import { defaultAnimalWeights } from "./state.js"; +import { eggTypeLabel, animalLabel, salesPanelAriaLabel } from "./texts-fr.js"; +import { addSalesPanelSellerSection, addSalesPanelBuyerSection, addSalesPanelActiveSection } from "./ui-world-map-sales.js"; +import { renderCities } from "./ui-world-map-cities.js"; +import { renderLab, renderTruckAndNpcTrucks } from "./ui-world-map-trucks.js"; + +const EGG_EMOJI = "🥚"; +const WORLD_MAP_GRID_COLS = 12; +const WORLD_MAP_GRID_ROWS = 8; + +/** + * @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record }} ctx + * @param {import("./api-client.js").SalesFromApi | null} api + * @param {string} playerZooId + */ +function renderSalesPanel(ctx, api, playerZooId) { + const salesPanel = document.createElement("div"); + salesPanel.className = "world-map-sales-panel"; + salesPanel.setAttribute("aria-label", salesPanelAriaLabel); + if (api) { + addSalesPanelSellerSection(salesPanel, api, ctx); + addSalesPanelBuyerSection(salesPanel, api, ctx); + addSalesPanelActiveSection(salesPanel, api, playerZooId, ctx); + } + if (salesPanel.childNodes.length > 0) ctx.worldMapEl.appendChild(salesPanel); +} + +/** + * @param {{ worldMapEl: HTMLElement }} ctx + */ +function renderCellsLayer(ctx) { + 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); + } + } + ctx.worldMapEl.appendChild(cellsLayer); +} + +/** + * @param {HTMLElement} slotEl + * @param {Array<{ animalId: string, isBaby: boolean, price: number }>} listings + * @param {{ animalEmoji: Record }} ctx + */ +function addZooSlotListings(slotEl, listings, ctx) { + for (const listing of listings.slice(0, 3)) { + const el = document.createElement("div"); + el.className = "world-map-sale-listing"; + const emoji = ctx.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); + } +} + +/** + * @param {HTMLElement} slotEl + * @param {{ eggType: string, price: number }} offer + * @param {string} zooId + * @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} ctx + */ +function addZooSlotNpcOffer(slotEl, offer, zooId, ctx) { + const { setState, setError } = ctx; + 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[offer.eggType] ?? offer.eggType; + el.innerHTML = `${EGG_EMOJI}${name}${offer.price} pièces`; + let dragStarted = false; + el.addEventListener("dragstart", (e) => { + dragStarted = true; + e.dataTransfer.setData("application/x-builazoo-eggtype", offer.eggType); + e.dataTransfer.setData("application/x-builazoo-offer-zooid", zooId); + 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); +} + +/** + * @param {HTMLElement} slotEl + * @param {{ animalId: string, price: number } | null} babyOffer + * @param {{ animalId: string, price: number } | null} animalOffer + * @param {{ animalEmoji: Record }} ctx + */ +function addZooSlotPlayerOffers(slotEl, babyOffer, animalOffer, ctx) { + if (babyOffer) { + const el = document.createElement("div"); + el.className = "offer-btn world-map-offer"; + el.setAttribute("draggable", "true"); + const emoji = ctx.animalEmoji[babyOffer.animalId] ?? "🐾"; + const name = animalLabel[babyOffer.animalId] ?? babyOffer.animalId; + el.innerHTML = `${emoji}Bébé ${name}${babyOffer.price}`; + el.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("application/x-builazoo-baby-offer", `${babyOffer.animalId}:${babyOffer.price}`); + e.dataTransfer.effectAllowed = "copy"; + }); + slotEl.appendChild(el); + } + if (animalOffer) { + const el = document.createElement("div"); + el.className = "offer-btn world-map-offer"; + el.setAttribute("draggable", "true"); + const emoji = ctx.animalEmoji[animalOffer.animalId] ?? "🐾"; + const name = animalLabel[animalOffer.animalId] ?? animalOffer.animalId; + el.innerHTML = `${emoji}${name}${animalOffer.price}`; + el.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("application/x-builazoo-animal-offer", `${animalOffer.animalId}:${animalOffer.price}`); + e.dataTransfer.effectAllowed = "copy"; + }); + slotEl.appendChild(el); + } +} + +/** + * @param {{ slotEl: HTMLElement, zoo: import("./types.js").WorldZoo, isPlayer: boolean, zooListings: Array<{ animalId: string, isBaby: boolean, price: number }>, oneOffer: import("./conveyor.js").ConveyorOffer | null, playerBabyOffer: { animalId: string, price: number } | null, playerAnimalOffer: { animalId: string, price: number } | null, ctx: { animalEmoji: Record, setState: () => void, setError: (s: string) => void } }} opts + */ +function fillZooSlotContent(opts) { + const { slotEl, zoo, isPlayer, zooListings, oneOffer, playerBabyOffer, playerAnimalOffer, ctx } = opts; + if (isPlayer && zooListings.length > 0) { + addZooSlotListings(slotEl, zooListings, ctx); + } else if (oneOffer) { + addZooSlotNpcOffer(slotEl, oneOffer, zoo.id, ctx); + } else if (isPlayer && (playerBabyOffer || playerAnimalOffer)) { + addZooSlotPlayerOffers(slotEl, playerBabyOffer, playerAnimalOffer, ctx); + } else { + const iconEl = document.createElement("span"); + iconEl.className = "world-map-zoo-icon"; + iconEl.setAttribute("aria-hidden", "true"); + iconEl.textContent = "🏠"; + slotEl.appendChild(iconEl); + } +} + +/** + * @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record }} ctx + * @param {import("./types.js").WorldZoo} zoo + * @param {Array<{ animalId: string, isBaby: boolean, price: number }>} zooListingsForPlayer + * @param {Array} offers + */ +function buildZooNode(ctx, zoo, zooListingsForPlayer, offers) { + const { state, worldMapEl } = ctx; + 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 : []; + fillZooSlotContent({ slotEl, zoo, isPlayer, zooListings, oneOffer, playerBabyOffer: playerBabyOffer ?? null, playerAnimalOffer: playerAnimalOffer ?? null, ctx }); + node.appendChild(slotEl); + worldMapEl.appendChild(node); +} + +/** + * @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record }} ctx + * @param {Array} zoos + * @param {Array} offers + * @param {Array<{ animalId: string, isBaby: boolean, price: number }>} zooListingsForPlayer + */ +function renderZoos(ctx, zoos, offers, zooListingsForPlayer) { + for (const zoo of zoos) { + buildZooNode(ctx, zoo, zooListingsForPlayer, offers); + } +} + +/** + * @param {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, animalEmoji: Record, pendingTokenByEggType: Record }} ctx + */ +export function renderWorldMap(ctx) { + ctx.worldMapEl.innerHTML = ""; + const playerZooId = ctx.state.myZooId ?? "player"; + const api = ctx.state.salesFromApi; + const myListingsFromApi = api?.asSeller ? api.asSeller.map(mapServerListingToClient) : null; + const zooListingsForPlayer = myListingsFromApi ?? (ctx.state.saleListings ?? []).filter((s) => s.zooId === playerZooId); + renderSalesPanel(ctx, api, playerZooId); + renderCellsLayer(ctx); + const zoos = ctx.state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }]; + const offers = ctx.state.conveyorOffers || []; + renderZoos(ctx, zoos, offers, zooListingsForPlayer); + renderCities(ctx); + renderLab(ctx); + renderTruckAndNpcTrucks(ctx, zoos); +} + +export { renderSalesPanel, renderCellsLayer, renderZoos, WORLD_MAP_GRID_COLS, WORLD_MAP_GRID_ROWS, EGG_EMOJI }; +export { renderCities } from "./ui-world-map-cities.js"; diff --git a/web/js/ui.js b/web/js/ui.js index 3f25085..78bd23c 100644 --- a/web/js/ui.js +++ b/web/js/ui.js @@ -1,57 +1,6 @@ -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"; +import { buildUIDOM } from "./ui-render-dom.js"; -const EGG_EMOJI = "🥚"; const EMOJI_BY_COLOR = ["🐰", "🦌", "🐸", "🦎", "🐢", "🐬", "🦭", "🐟", "🦈", "🐳", "🦅", "🐺", "🐻", "🦊", "🐗"]; const animalEmoji = {}; for (let c = 0; c < 15; c++) { @@ -61,67 +10,26 @@ for (let c = 0; c < 15; 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} + * @returns {object} */ -export function render(root, opts) { +function buildRenderSetup(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 selectedTokenIdRef = { current: null }; + const emptyCellChoiceRef = { current: null }; const errorMsg = { current: "" }; - let lastActionWasDrop = false; - let sellZoneJustDropped = false; - + const lastActionWasDropRef = { current: false }; + const sellZoneJustDroppedRef = { current: 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 errEl = document.createElement("div"); + errEl.className = "error-msg"; + errEl.hidden = true; const setError = (msg) => { errorMsg.current = msg; if (errEl) { @@ -129,1478 +37,25 @@ export function render(root, opts) { 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(); + return { + state, setState, getHatched, updateState, onRestart, + selected, pendingTokenByEggType, selectedTokenIdRef, emptyCellChoiceRef, + errorMsg, lastActionWasDropRef, sellZoneJustDroppedRef, + clampSelection, setError, errEl, animalEmoji, }; - - fullRender(); - return fullRender; +} + +/** + * @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 phase = getTimePhase(opts.state.timeOfDay ?? 6); + const weather = opts.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 setup = buildRenderSetup(opts); + return buildUIDOM(root, setup); } diff --git a/web/js/visitor-incidents.js b/web/js/visitor-incidents.js index 0ec1f26..a905bbf 100644 --- a/web/js/visitor-incidents.js +++ b/web/js/visitor-incidents.js @@ -11,24 +11,92 @@ 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 truck (egg or sale) is in progress. + * @param {import("./types.js").GameState} state + * @returns {boolean} + */ +function hasTruckWait(state) { + if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true; + if (state.truckSale && state.truckSale.startAt) return true; + return false; +} + +/** + * True when there are API sales as buyer undelivered with pending validation. + * @param {import("./types.js").GameState} state + * @returns {boolean} + */ +function hasApiUndeliveredWait(state) { + const api = state.salesFromApi; + if (!api || !api.asBuyerUndelivered || api.asBuyerUndelivered.length === 0) return false; + 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; +} + /** * 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 hasTruckWait(state) || hasApiUndeliveredWait(state); +} + +/** + * Expire timed-out incidents and apply penalty. Returns indices to remove from arrivals. + * @param {import("./types.js").VisitorArrival[]} arrivals + * @param {{ nowUnix: number, timeoutSec: number, penalty: number, stateRef: { attractivityBonusFromIncidents: number } }} opts + * @returns {number[]} + */ +function expireIncidents(arrivals, opts) { + const { nowUnix, timeoutSec, penalty, stateRef } = opts; + 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) { + stateRef.attractivityBonusFromIncidents = (stateRef.attractivityBonusFromIncidents ?? 0) - penalty; + toRemove.push(i); + } } } - return false; + return toRemove; +} + +/** + * Spawn new incidents on visitors without one (by chance). + * @param {import("./types.js").VisitorArrival[]} arrivals + * @param {number} chance + * @param {number} nowUnix + */ +function spawnIncidents(arrivals, chance, nowUnix) { + for (let i = 0; i < arrivals.length; i++) { + const v = arrivals[i]; + const hasNoIncident = v.incidentType === null || v.incidentType === undefined; + if (hasNoIncident && Math.random() < chance) { + v.incidentType = INCIDENT_TYPES[Math.floor(Math.random() * INCIDENT_TYPES.length)]; + v.incidentSince = nowUnix; + } + } +} + +/** + * @returns {{ baseChance: number, waitMult: number, timeoutSec: number, penalty: number }} + */ +function getIncidentConfig() { + const cfg = GameConfig.Visitor; + return { + baseChance: cfg?.IncidentChanceBase ?? 0.002, + waitMult: cfg?.IncidentChanceWaitMultiplier ?? 4, + timeoutSec: cfg?.IncidentTimeoutSeconds ?? 45, + penalty: cfg?.IncidentUnresolvedAttractivityPenalty ?? 0.2, + }; } /** @@ -38,30 +106,30 @@ export function isInWaitPhase(state) { */ 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 { baseChance, waitMult, timeoutSec, penalty } = getIncidentConfig(); 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; - } - } + const toRemove = expireIncidents(arrivals, { nowUnix, timeoutSec, penalty, stateRef: state }); for (let r = toRemove.length - 1; r >= 0; r--) { arrivals.splice(toRemove[r], 1); } + spawnIncidents(arrivals, chance, nowUnix); +} + +/** + * Apply resolve bonus (coins + attractivity) to state. Mutates state and v. + * @param {import("./types.js").GameState} state + * @param {import("./types.js").VisitorArrival} v + */ +function applyResolveBonus(state, v) { + 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; } /** @@ -74,13 +142,6 @@ 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; + applyResolveBonus(state, v); return true; } diff --git a/web/js/zoo-placement.js b/web/js/zoo-placement.js new file mode 100644 index 0000000..54c88b5 --- /dev/null +++ b/web/js/zoo-placement.js @@ -0,0 +1,90 @@ +import { LootTables } from "./loot-tables.js"; +import { canPlaceMultiCell } from "./placement.js"; + +/** + * @param {import("./types.js").GameState} state + * @param {string} nurseryCellKey + * @param {number} nowUnix + * @returns {[false, string] | [true, import("./types.js").PendingBaby]} + */ +function getMatureBabyReady(state, nurseryCellKey, nowUnix) { + const baby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === nurseryCellKey); + if (baby === null || baby === undefined) return [false, "NoBaby"]; + if (nowUnix < baby.readyAt) return [false, "BabyNotReady"]; + return [true, baby]; +} + +/** + * @param {import("./types.js").GameState} state + * @param {{ nurseryCellKey: string, toX: number, toY: number, nowUnix: number }} opts + * @returns {[false, string] | [true, import("./types.js").AnimalCell]} + */ +export function getMatureBabyPlacementData(state, opts) { + const { nurseryCellKey, toX, toY, nowUnix } = opts; + const babyResult = getMatureBabyReady(state, nurseryCellKey, nowUnix); + if (babyResult[0] === false) return [false, babyResult[1]]; + const baby = babyResult[1]; + 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 ?? "NoPlace"]; + 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, + }; + return [true, animalData]; +} + +/** + * @param {import("./types.js").GameState} state + * @param {string} receptionCellKey + * @param {number} nowUnix + * @returns {[false, string] | [true, import("./types.js").ReceptionAnimal]} + */ +function getReceptionAnimalReady(state, receptionCellKey, nowUnix) { + const rec = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === receptionCellKey); + if (rec === null || rec === undefined) return [false, "NoReceptionAnimal"]; + if (nowUnix < rec.readyAt) return [false, "AnimalNotReady"]; + return [true, rec]; +} + +/** + * @param {import("./types.js").GameState} state + * @param {{ receptionCellKey: string, toX: number, toY: number, nowUnix: number }} opts + * @returns {[false, string] | [true, import("./types.js").AnimalCell]} + */ +export function getReceptionAnimalPlacementData(state, opts) { + const { receptionCellKey, toX, toY, nowUnix } = opts; + const recResult = getReceptionAnimalReady(state, receptionCellKey, nowUnix); + if (recResult[0] === false) return [false, recResult[1]]; + const rec = recResult[1]; + 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 ?? "NoPlace"]; + const animalData = { + kind: "animal", + id: rec.animalId, + mutation: "none", + level: 1, + placedAt: nowUnix, + lastVisitedAt: nowUnix, + lastFedAt: nowUnix, + cellsWide: w, + cellsHigh: h, + fromOtherZoo: true, + }; + return [true, animalData]; +} diff --git a/web/js/zoo.js b/web/js/zoo.js index e0340ae..9298d70 100644 --- a/web/js/zoo.js +++ b/web/js/zoo.js @@ -3,7 +3,8 @@ 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 { placeEgg, fillAnimalBlock } from "./placement.js"; +import { getMatureBabyPlacementData, getReceptionAnimalPlacementData } from "./zoo-placement.js"; import { getNurseryCellKeysOrdered, getFreeNurseryCellKey } from "./zoo-nursery.js"; export { getNurseryCellKeysOrdered, getFreeNurseryCellKey }; @@ -125,28 +126,9 @@ export function tryBuyAnimal(state, animalId, price) { */ 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); + const [ok, result] = getMatureBabyPlacementData(state, opts); + if (!ok) return [false, result]; + fillAnimalBlock(state, toX, toY, result); state.pendingBabies = (state.pendingBabies ?? []).filter((p) => p.nurseryCellKey !== nurseryCellKey); state.lastEvolutionAt = nowUnix; return [true, undefined]; @@ -160,28 +142,9 @@ export function placeMatureBabyOnCell(state, opts) { */ 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); + const [ok, result] = getReceptionAnimalPlacementData(state, opts); + if (!ok) return [false, result]; + fillAnimalBlock(state, toX, toY, result); state.receptionAnimals = (state.receptionAnimals ?? []).filter((r) => r.receptionCellKey !== receptionCellKey); state.lastEvolutionAt = nowUnix; return [true, undefined];