Lint: fix errors and remove unused variables
**Motivations:** - Ensure lint config is not degraded and fix all lint errors for pousse workflow. **Root causes:** - Unused variables kept with _ prefix instead of removed (_row, _questReward, _i). - getAnimalBlockOrigin had 5 parameters (max 4). - use of continue statement (no-continue rule). **Correctifs:** - ESLint config verified; no eslint-disable in codebase. - Removed unused variable _row (biome-rules); removed dead function _questReward (quests); removed unused map param _i (state.js). - getAnimalBlockOrigin refactored to 4 params (pos object instead of x, y). - Replaced continue with if (cell) block in normalizeLoadedCells (state.js). - JSDoc param names aligned with _height, _y (biome-rules). **Evolutions:** - (none) **Pages affectées:** - web/js/biome-rules.js - web/js/quests.js - web/js/state.js - web/js/placement.js
This commit is contained in:
25
docs/features/billeterie-flux.md
Normal file
25
docs/features/billeterie-flux.md
Normal file
@@ -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).
|
||||||
@@ -28,14 +28,15 @@
|
|||||||
|
|
||||||
## Non implémenté
|
## 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.
|
- **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.
|
- **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é (actuellement seul le bébé invendu est compté).
|
- **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
|
## Fichiers
|
||||||
|
|
||||||
- `web/js/food.js` : `checkDeathCauses`, `maybeDeathBlock`, `filterPendingBabies`, `filterReceptionAnimals`.
|
- `web/js/food.js` : `checkDeathCauses`, `maybeDeathBlock`, `checkNotAlone`, `filterPendingBabies`, `filterReceptionAnimals` ; cause recherche (getSkillLevel), cause seuls (Animal config).
|
||||||
- `web/js/trade.js` : `tickSaleListings` (expiration bébé).
|
- `web/js/trade.js` : `tickSaleListings` (expiration bébé et adulte → deathCountRecent).
|
||||||
- `web/js/animal-visits.js` : `lastVisitedAt` pour cause « pas visités ».
|
- `web/js/animal-visits.js` : `lastVisitedAt` pour cause « pas visités ».
|
||||||
- `server/db.js` : `expireSaleListings` (bébé invendu).
|
- `server/db.js` : `expireSaleListings` (bébé invendu).
|
||||||
|
- `web/js/config.js` : `Animal.MinSameSpeciesInRadius`, `RadiusCells`, `MaxSecondsAlone`.
|
||||||
|
|||||||
24
docs/features/feedbacks-visuels-animaux.md
Normal file
24
docs/features/feedbacks-visuels-animaux.md
Normal file
@@ -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.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
- **state.js**
|
- **state.js**
|
||||||
- `buildDefaultCells()` : appelle `buildDefaultRow1Cells()` du module partagé `default-grid-layout.js` (research, billeterie, nursery, reception, food, school en ligne 1).
|
- `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.
|
- `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**
|
- **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`.
|
- 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`.
|
||||||
|
|
||||||
|
|||||||
36
docs/features/saisons-phase.md
Normal file
36
docs/features/saisons-phase.md
Normal file
@@ -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.
|
||||||
42
docs/features/ui-render-extraction.md
Normal file
42
docs/features/ui-render-extraction.md
Normal file
@@ -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).
|
||||||
0
docs/leo.md
Normal file
0
docs/leo.md
Normal file
@@ -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é
|
|
||||||
192
docs/plan-implementation-specs-animaux-billeterie.md
Normal file
192
docs/plan-implementation-specs-animaux-billeterie.md
Normal file
@@ -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 |
|
||||||
@@ -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.
|
|
||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -260,7 +260,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -469,7 +468,6 @@
|
|||||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint web/js server --ignore-pattern '**/node_modules/**'",
|
"lint": "eslint web/js server --ignore-pattern '**/node_modules/**'",
|
||||||
"lint:web": "eslint web/js",
|
"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": {
|
"devDependencies": {
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
|
|||||||
227
server/db-core.js
Normal file
227
server/db-core.js
Normal file
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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<string, unknown>} 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<Array<{ id: string, name: string, x: number, y: number, animal_weights: object, game_state: object | null }>>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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<number>}
|
||||||
|
*/
|
||||||
|
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<string>} 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<Array<{ id: string, name: string, x: number, y: number, animalWeights: object, botState: object }>>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
267
server/db-sales.js
Normal file
267
server/db-sales.js
Normal file
@@ -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<string, unknown>} 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<Array<{ 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, reproduction_score_at_sale: number | null }>>}
|
||||||
|
*/
|
||||||
|
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<object>, asBuyerUndelivered: Array<object>, active: Array<object> }>}
|
||||||
|
*/
|
||||||
|
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<number>} 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<number>} 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;
|
||||||
|
}
|
||||||
516
server/db.js
516
server/db.js
@@ -1,489 +1,29 @@
|
|||||||
import pg from "pg";
|
export {
|
||||||
import parse from "pg-connection-string";
|
pool,
|
||||||
import { createInitialBotState } from "./bot-state.js";
|
getMapParams,
|
||||||
|
getAccountByPublicKey,
|
||||||
|
createAccount,
|
||||||
|
updateLastSeen,
|
||||||
|
getZooByAccountId,
|
||||||
|
getAllZoos,
|
||||||
|
createZoo,
|
||||||
|
getZooById,
|
||||||
|
updateZooGameState,
|
||||||
|
countPlayerZoos,
|
||||||
|
createBotZoo,
|
||||||
|
getBotZoosForTick,
|
||||||
|
updateBotZooState,
|
||||||
|
} from "./db-core.js";
|
||||||
|
|
||||||
const { Pool } = pg;
|
export {
|
||||||
|
createSaleListing,
|
||||||
const connectionString = process.env.DATABASE_URL || "postgres://localhost/builazoo";
|
getSaleListingById,
|
||||||
const parsed = parse(connectionString);
|
getActiveSaleListings,
|
||||||
const poolConfig = {
|
getSalesForZoo,
|
||||||
host: parsed.host || "localhost",
|
placeBid,
|
||||||
port: Number(parsed.port) || 5432,
|
acceptSale,
|
||||||
database: parsed.database || "builazoo",
|
processValidatedSales,
|
||||||
user: parsed.user,
|
rejectSale,
|
||||||
password: typeof parsed.password === "string" ? parsed.password : "",
|
markSaleDelivered,
|
||||||
};
|
expireSaleListings,
|
||||||
if (process.env.PGPASSWORD !== undefined && process.env.PGPASSWORD !== null) poolConfig.password = String(process.env.PGPASSWORD);
|
} from "./db-sales.js";
|
||||||
|
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
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<string, unknown>} 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<Array<{ id: string, name: string, x: number, y: number, animal_weights: object, game_state: object | null }>>}
|
|
||||||
*/
|
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
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<number>}
|
|
||||||
*/
|
|
||||||
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<string>} 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<Array<{ id: string, name: string, x: number, y: number, animalWeights: object, botState: object }>>}
|
|
||||||
*/
|
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
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<string, unknown>} 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<Array<{ 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, reproduction_score_at_sale: number | null }>>}
|
|
||||||
*/
|
|
||||||
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<object>, asBuyerUndelivered: Array<object>, active: Array<object> }>}
|
|
||||||
*/
|
|
||||||
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<number>} 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<number>} 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 };
|
|
||||||
|
|||||||
@@ -17,6 +17,35 @@ import { verifySignature, buildSignMessage, hashBody } from "../auth.js";
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
|
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() {
|
function requireSignature() {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const publicKey = req.headers["x-public-key"];
|
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" });
|
res.status(401).json({ error: "Missing X-Public-Key, X-Signature, or X-Timestamp" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
if (!isTimestampValid(timestamp)) {
|
||||||
const ts = new Date(timestamp).getTime();
|
|
||||||
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
|
|
||||||
res.status(401).json({ error: "Invalid or expired timestamp" });
|
res.status(401).json({ error: "Invalid or expired timestamp" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
|
if (!isSignatureValid(req, publicKey, signature, timestamp)) {
|
||||||
const bodyHash = hashBody(body);
|
|
||||||
const path = req.originalUrl || req.baseUrl + req.path || req.path;
|
|
||||||
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
|
|
||||||
if (!verifySignature(publicKey, signature, message)) {
|
|
||||||
res.status(401).json({ error: "Invalid signature" });
|
res.status(401).json({ error: "Invalid signature" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,17 +83,7 @@ function optionalSignature() {
|
|||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
if (!isTimestampValid(timestamp) || !isSignatureValid(req, publicKey, signature, timestamp)) {
|
||||||
const ts = new Date(timestamp).getTime();
|
|
||||||
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
|
|
||||||
const bodyHash = hashBody(body);
|
|
||||||
const path = req.originalUrl || req.baseUrl + req.path || req.path;
|
|
||||||
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
|
|
||||||
if (!verifySignature(publicKey, signature, message)) {
|
|
||||||
next();
|
next();
|
||||||
return;
|
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.
|
||||||
router.get("/", optionalSignature(), async (req, res, next) => {
|
* @param {import("express").Request} req
|
||||||
try {
|
* @returns {Promise<{ active: Array<object> } | { asSeller: Array<object>, asBuyerUndelivered: Array<object>, active: Array<object> }>}
|
||||||
|
*/
|
||||||
|
async function getSalesResponse(req) {
|
||||||
await expireSaleListings();
|
await expireSaleListings();
|
||||||
if (req.account) {
|
if (!req.account) {
|
||||||
|
const active = await getActiveSaleListings();
|
||||||
|
return { active };
|
||||||
|
}
|
||||||
await processValidatedSales();
|
await processValidatedSales();
|
||||||
const zoo = await getZooByAccountId(req.account.id);
|
const zoo = await getZooByAccountId(req.account.id);
|
||||||
if (!zoo) {
|
if (!zoo) {
|
||||||
res.json({ asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() });
|
return { asSeller: [], asBuyerUndelivered: [], active: await getActiveSaleListings() };
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const data = await getSalesForZoo(zoo.id);
|
return getSalesForZoo(zoo.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/", optionalSignature(), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const data = await getSalesResponse(req);
|
||||||
res.json(data);
|
res.json(data);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const active = await getActiveSaleListings();
|
|
||||||
res.json({ active });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(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? }. */
|
/** POST /api/sales — create listing (auth). Body: { animalId, isBaby, price, endAt, reproductionScoreAtSale? }. */
|
||||||
router.post("/", requireSignature(), async (req, res, next) => {
|
router.post("/", requireSignature(), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -111,32 +158,18 @@ router.post("/", requireSignature(), async (req, res, next) => {
|
|||||||
res.status(404).json({ error: "No zoo for this account" });
|
res.status(404).json({ error: "No zoo for this account" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { animalId, isBaby, price, endAt, reproductionScoreAtSale } = req.body ?? {};
|
const parsed = parseCreateListingBody(req.body);
|
||||||
if (typeof animalId !== "string" || !animalId.trim()) {
|
if (!parsed.ok) {
|
||||||
res.status(400).json({ error: "animalId required" });
|
res.status(parsed.status).json({ error: parsed.error });
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof isBaby !== "boolean") {
|
|
||||||
res.status(400).json({ error: "isBaby boolean required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const initialPrice = Number(price);
|
|
||||||
if (!Number.isFinite(initialPrice) || initialPrice < 0) {
|
|
||||||
res.status(400).json({ error: "price must be a non-negative number" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const endAtDate = endAt ? new Date(endAt) : null;
|
|
||||||
if (!endAtDate || Number.isNaN(endAtDate.getTime())) {
|
|
||||||
res.status(400).json({ error: "endAt required (ISO date string)" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { id } = await createSaleListing({
|
const { id } = await createSaleListing({
|
||||||
sellerZooId: zoo.id,
|
sellerZooId: zoo.id,
|
||||||
animalId: animalId.trim(),
|
animalId: parsed.animalId,
|
||||||
isBaby,
|
isBaby: parsed.isBaby,
|
||||||
initialPrice,
|
initialPrice: parsed.initialPrice,
|
||||||
endAt: endAtDate.toISOString(),
|
endAt: parsed.endAtDate.toISOString(),
|
||||||
reproductionScoreAtSale: reproductionScoreAtSale != null ? Number(reproductionScoreAtSale) : undefined,
|
reproductionScoreAtSale: parsed.reproductionScoreAtSale,
|
||||||
});
|
});
|
||||||
res.status(201).json({ id });
|
res.status(201).json({ id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ import { verifySignature, buildSignMessage, hashBody } from "../auth.js";
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
|
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() {
|
function requireSignature() {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const publicKey = req.headers["x-public-key"];
|
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" });
|
res.status(401).json({ error: "Missing X-Public-Key, X-Signature, or X-Timestamp" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
if (!isTimestampValid(timestamp)) {
|
||||||
const ts = new Date(timestamp).getTime();
|
|
||||||
if (Number.isNaN(ts) || Math.abs(now - ts) > TIMESTAMP_TOLERANCE_MS) {
|
|
||||||
res.status(401).json({ error: "Invalid or expired timestamp" });
|
res.status(401).json({ error: "Invalid or expired timestamp" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = req.bodyRaw !== undefined && req.bodyRaw !== null ? req.bodyRaw : "";
|
if (!isSignatureValid(req, publicKey, signature, timestamp)) {
|
||||||
const bodyHash = hashBody(body);
|
|
||||||
const path = req.originalUrl || req.baseUrl + req.path || req.path;
|
|
||||||
const message = buildSignMessage(req.method, path, timestamp, bodyHash);
|
|
||||||
if (!verifySignature(publicKey, signature, message)) {
|
|
||||||
res.status(401).json({ error: "Invalid signature" });
|
res.status(401).json({ error: "Invalid signature" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,9 +60,9 @@ function requireSignature() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/zoos — list zoos for map (no auth). Ensures minZoos with bots. */
|
/** @returns {Promise<{ zoos: Array<object>, mapWidth: number, mapHeight: number }>}
|
||||||
router.get("/", async (req, res, next) => {
|
*/
|
||||||
try {
|
async function getZoosForMap() {
|
||||||
const params = await getMapParams();
|
const params = await getMapParams();
|
||||||
let zoos = await getAllZoos();
|
let zoos = await getAllZoos();
|
||||||
await countPlayerZoos();
|
await countPlayerZoos();
|
||||||
@@ -63,6 +74,17 @@ router.get("/", async (req, res, next) => {
|
|||||||
await createBotZoo(x, y, weights);
|
await createBotZoo(x, y, weights);
|
||||||
}
|
}
|
||||||
if (need > 0) zoos = await getAllZoos();
|
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 { zoos, mapWidth, mapHeight } = await getZoosForMap();
|
||||||
const worldZoos = zoos.map((z) => ({
|
const worldZoos = zoos.map((z) => ({
|
||||||
id: z.id,
|
id: z.id,
|
||||||
name: z.name,
|
name: z.name,
|
||||||
@@ -71,7 +93,7 @@ router.get("/", async (req, res, next) => {
|
|||||||
animalWeights: z.animal_weights,
|
animalWeights: z.animal_weights,
|
||||||
game_state: z.game_state ?? null,
|
game_state: z.game_state ?? null,
|
||||||
}));
|
}));
|
||||||
res.json({ worldZoos, mapWidth: params.mapWidth, mapHeight: params.mapHeight });
|
res.json({ worldZoos, mapWidth, mapHeight });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1374,6 +1374,30 @@ body.bg-phase-night.bg-weather-rain { background: linear-gradient(160deg, #080a0
|
|||||||
font-weight: 600;
|
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 {
|
.cell .cell-emoji {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1445,6 +1469,18 @@ body.bg-phase-night.bg-weather-rain { background: linear-gradient(160deg, #080a0
|
|||||||
min-height: 0;
|
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 */
|
/* Bulles d'aide */
|
||||||
.help-wrap {
|
.help-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
101
web/js/animal-visual-state.js
Normal file
101
web/js/animal-visual-state.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -8,16 +8,14 @@ export const BIOMES = ["Meadow", "Freshwater", "Ocean", "Forest", "Mountain"];
|
|||||||
/**
|
/**
|
||||||
* Base biome from grid position (5 zones by column).
|
* Base biome from grid position (5 zones by column).
|
||||||
* @param {number} width
|
* @param {number} width
|
||||||
* @param {number} height
|
* @param {number} _height
|
||||||
* @param {number} x 1-based column
|
* @param {number} x 1-based column
|
||||||
* @param {number} y 1-based row
|
* @param {number} _y 1-based row
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getCellBiome(width, height, x, y) {
|
export function getCellBiome(width, _height, x, _y) {
|
||||||
const w = Math.max(1, width);
|
const w = Math.max(1, width);
|
||||||
const h = Math.max(1, height);
|
|
||||||
const col = Math.max(1, Math.min(w, Math.floor(x)));
|
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 t = Math.floor((col - 1) / (w / 5));
|
||||||
const index = Math.min(4, Math.max(0, t));
|
const index = Math.min(4, Math.max(0, t));
|
||||||
return BIOMES[index] ?? "Meadow";
|
return BIOMES[index] ?? "Meadow";
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ export function getZooSkillLevel(zoo) {
|
|||||||
return b ? b.conveyorLevel : 1;
|
return b ? b.conveyorLevel : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, number>} out
|
||||||
|
* @param {Record<string, number>} 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).
|
* Neighbor color weights (sum of animalWeights of zoos within maxDistance on the map).
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").GameState} state
|
||||||
@@ -73,10 +83,7 @@ export function getNeighborColorWeights(state, zooId) {
|
|||||||
const dx = (z.x - self.x) / 100;
|
const dx = (z.x - self.x) / 100;
|
||||||
const dy = (z.y - self.y) / 100;
|
const dy = (z.y - self.y) / 100;
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy) * 100;
|
const dist = Math.sqrt(dx * dx + dy * dy) * 100;
|
||||||
if (dist <= maxD) {
|
if (dist <= maxD) addNeighborWeights(out, z.animalWeights ?? {}, colorNames);
|
||||||
const w = z.animalWeights ?? {};
|
|
||||||
for (const c of colorNames) out[c] = (out[c] ?? 0) + (w[c] ?? 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -150,27 +157,13 @@ export function ensureBotState(zoo, isPlayer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").BotState} b
|
||||||
* @param {import("./types.js").WorldZooEntry} zoo
|
* @param {string} choice
|
||||||
* @param {{ b: import("./types.js").BotState, rng: () => number, params: { spendThreshold: number, upgradeChance: number } }} opts
|
* @param {{ plotCost: number, skillCost: number, truckCost: number }} costs
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function botDecideUpgrade(state, zoo, opts) {
|
function applyBotUpgradeChoice(b, choice, costs) {
|
||||||
const { b, rng, params } = opts;
|
const { plotCost, skillCost, truckCost } = costs;
|
||||||
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)];
|
|
||||||
if (choice === "plot" && b.coins >= plotCost) {
|
if (choice === "plot" && b.coins >= plotCost) {
|
||||||
b.coins -= plotCost;
|
b.coins -= plotCost;
|
||||||
b.plotLevel += 1;
|
b.plotLevel += 1;
|
||||||
@@ -189,6 +182,28 @@ function botDecideUpgrade(state, zoo, opts) {
|
|||||||
return false;
|
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 {import("./types.js").WorldZooEntry} zoo
|
||||||
* @param {() => number} rng
|
* @param {() => number} rng
|
||||||
@@ -288,6 +303,40 @@ export function tickBotZoos(state, nowUnix, dt) {
|
|||||||
const PLAYER_AUTO_MIN_INTERVAL = 10;
|
const PLAYER_AUTO_MIN_INTERVAL = 10;
|
||||||
const PLAYER_AUTO_MAX_INTERVAL = 28;
|
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.
|
* Apply one upgrade (plot, skill, truck) for player auto mode.
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").GameState} state
|
||||||
@@ -296,24 +345,10 @@ const PLAYER_AUTO_MAX_INTERVAL = 28;
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function playerAutoDoOneUpgrade(state, params, rng) {
|
function playerAutoDoOneUpgrade(state, params, rng) {
|
||||||
const { spendThreshold, upgradeChance } = params;
|
const choices = getPlayerUpgradeChoices(state, params);
|
||||||
const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels();
|
if (choices.length === 0 || rng() >= params.upgradeChance) return;
|
||||||
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 choice = choices[Math.floor(rng() * choices.length)];
|
const choice = choices[Math.floor(rng() * choices.length)];
|
||||||
if (choice === "plot") tryUpgradePlot(state);
|
applyPlayerUpgradeChoice(state, choice);
|
||||||
else if (choice === "skill") tryUpgrade(state);
|
|
||||||
else if (choice === "truck") tryUpgradeTruck(state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ export const GameConfig = {
|
|||||||
BuildCost: 280,
|
BuildCost: 280,
|
||||||
BaseUpgradeCost: 280,
|
BaseUpgradeCost: 280,
|
||||||
UpgradeGrowth: 1.55,
|
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é. */
|
/** Nourriture: 7 niveaux, 5 animaux/unité. */
|
||||||
@@ -136,6 +141,13 @@ export const GameConfig = {
|
|||||||
BaseChance: 0.06,
|
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: [],
|
Events: [],
|
||||||
|
|
||||||
Visitor: {
|
Visitor: {
|
||||||
@@ -146,35 +158,20 @@ export const GameConfig = {
|
|||||||
StagnationDecayPerMinute: 0.05,
|
StagnationDecayPerMinute: 0.05,
|
||||||
CityAttractionScale: 0.002,
|
CityAttractionScale: 0.002,
|
||||||
AnimalValueScale: 0.00015,
|
AnimalValueScale: 0.00015,
|
||||||
/** Seconds without any visitor on the cell before the animal disappears. */
|
|
||||||
MaxSecondsWithoutVisit: 300,
|
MaxSecondsWithoutVisit: 300,
|
||||||
/** Multiplier bonus per souvenir shop level applied to payment per visitor (e.g. 0.2 = +20% per shop). */
|
|
||||||
SouvenirShopBonusPerShop: 0.2,
|
SouvenirShopBonusPerShop: 0.2,
|
||||||
/** Chance per visitor to be a luxury guest (0–1). */
|
|
||||||
LuxuryGuestChance: 0.08,
|
LuxuryGuestChance: 0.08,
|
||||||
/** Entry payment multiplier for luxury guests. */
|
|
||||||
LuxuryEntryMultiplier: 3,
|
LuxuryEntryMultiplier: 3,
|
||||||
/** Extra shop spending multiplier for luxury guests (applied on top of normal shop bonus). */
|
|
||||||
LuxuryShopMultiplier: 2.5,
|
LuxuryShopMultiplier: 2.5,
|
||||||
/** Attractivity: penalty per recent death (subtracted from score). */
|
|
||||||
AttractivityDeathPenalty: 0.5,
|
AttractivityDeathPenalty: 0.5,
|
||||||
/** Attractivity: bonus per birth (added to score). */
|
|
||||||
AttractivityBirthBonus: 0.2,
|
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,
|
StayMultiplierPerShopLevel: 0.15,
|
||||||
/** Extra stay time per distinct animal species (e.g. 0.02 = +2% per species). */
|
|
||||||
StayMultiplierPerSpecies: 0.02,
|
StayMultiplierPerSpecies: 0.02,
|
||||||
/** Incident (soif, poubelle, banc, animal loin, photo): base chance per visitor per tick when not in wait phase. */
|
|
||||||
IncidentChanceBase: 0.002,
|
IncidentChanceBase: 0.002,
|
||||||
/** Multiplier to incident chance when in wait phase (truck, sale pending, etc.). */
|
|
||||||
IncidentChanceWaitMultiplier: 4,
|
IncidentChanceWaitMultiplier: 4,
|
||||||
/** Seconds before unresolved incident: visitor leaves and attractivity penalty applied. */
|
|
||||||
IncidentTimeoutSeconds: 45,
|
IncidentTimeoutSeconds: 45,
|
||||||
/** Attractivity bonus when player resolves an incident. */
|
|
||||||
IncidentResolveAttractivityBonus: 0.15,
|
IncidentResolveAttractivityBonus: 0.15,
|
||||||
/** Coin bonus when player resolves an incident. */
|
|
||||||
IncidentResolveCoinBonus: 8,
|
IncidentResolveCoinBonus: 8,
|
||||||
/** Attractivity penalty when incident times out unresolved. */
|
|
||||||
IncidentUnresolvedAttractivityPenalty: 0.2,
|
IncidentUnresolvedAttractivityPenalty: 0.2,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -183,17 +180,13 @@ export const GameConfig = {
|
|||||||
MaxLevel: 7,
|
MaxLevel: 7,
|
||||||
BaseUpgradeCost: 180,
|
BaseUpgradeCost: 180,
|
||||||
UpgradeGrowth: 1.5,
|
UpgradeGrowth: 1.5,
|
||||||
/** Seconds for a baby to become mature (divided by nursery level). */
|
|
||||||
GrowthSecondsBase: 40,
|
GrowthSecondsBase: 40,
|
||||||
/** Seconds a mature baby can wait without being placed before dying. */
|
|
||||||
MaxSecondsMatureNotPlaced: 90,
|
MaxSecondsMatureNotPlaced: 90,
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reproduction: delay between pair detection and baby birth; reduced by zoo score and biome/temp fit. */
|
/** Reproduction: delay between pair detection and baby birth; reduced by zoo score and biome/temp fit. */
|
||||||
Reproduction: {
|
Reproduction: {
|
||||||
/** Base seconds until baby is born for an eligible pair. */
|
|
||||||
BaseSeconds: 60,
|
BaseSeconds: 60,
|
||||||
/** Max Manhattan distance between blocks to count as adjacent (1 = edge-adjacent only). */
|
|
||||||
MaxDistance: 1,
|
MaxDistance: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -209,6 +202,16 @@ export const GameConfig = {
|
|||||||
PhaseShift: 0,
|
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: {
|
Weather: {
|
||||||
ChangeIntervalSeconds: 45,
|
ChangeIntervalSeconds: 45,
|
||||||
RainChance: 0.25,
|
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. */
|
/** Phase 10: sale listings (baby/animal on truck → world map). Bébé invendu meurt après ce délai. */
|
||||||
Sale: {
|
Sale: {
|
||||||
/** Seconds until a listing expires if not sold. After expiry, baby dies (deathCountRecent). */
|
|
||||||
ListingDurationSeconds: 3600,
|
ListingDurationSeconds: 3600,
|
||||||
/** Default asking price for a baby or animal put on sale. */
|
|
||||||
DefaultPrice: 50,
|
DefaultPrice: 50,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ export function getPlayerZooWeights(state) {
|
|||||||
return w;
|
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).
|
* Zoos that can offer this egg type (skill level allows it and zoo has weight for it).
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").GameState} state
|
||||||
@@ -60,17 +76,10 @@ export function getPlayerZooWeights(state) {
|
|||||||
*/
|
*/
|
||||||
function getZoosForEggType(state, eggType) {
|
function getZoosForEggType(state, eggType) {
|
||||||
const zoos = state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: DEFAULT_ZOO_WEIGHTS }];
|
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 = [];
|
const entries = [];
|
||||||
for (const zoo of zoos) {
|
for (const zoo of zoos) {
|
||||||
const skillLevel = zoo.id === "player" ? getSkillLevel(state) : getZooSkillLevel(zoo);
|
const info = getZooSkillAndWeightForEgg(state, zoo, eggType);
|
||||||
if (skillLevel >= minLevel) {
|
if (info) entries.push({ id: zoo.id, weight: info.weight });
|
||||||
const weights = zoo.id === "player" ? playerWeights : (zoo.animalWeights ?? {});
|
|
||||||
const w = weights[eggType] ?? 0;
|
|
||||||
if (w > 0) entries.push({ id: zoo.id, weight: w });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (entries.length === 0) entries.push({ id: "player", weight: 1 });
|
if (entries.length === 0) entries.push({ id: "player", weight: 1 });
|
||||||
return entries;
|
return entries;
|
||||||
|
|||||||
174
web/js/food.js
174
web/js/food.js
@@ -8,6 +8,8 @@ import { LootTables } from "./loot-tables.js";
|
|||||||
import { isOriginCell } from "./grid-utils.js";
|
import { isOriginCell } from "./grid-utils.js";
|
||||||
import { getBlockKeysFromCell } from "./placement.js";
|
import { getBlockKeysFromCell } from "./placement.js";
|
||||||
import { getDisplayBiome, getDisplayTemperature, isAnimalAllowedOnBiome } from "./biome-rules.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).
|
* Total food capacity = sum over food cells of (level × AnimalsPerUnit).
|
||||||
@@ -40,6 +42,21 @@ export function getOriginAnimalCount(state) {
|
|||||||
return n;
|
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.
|
* 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).
|
* 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) {
|
export function tickFeeding(state, nowUnix) {
|
||||||
const capacity = getFoodCapacity(state);
|
const capacity = getFoodCapacity(state);
|
||||||
if (capacity <= 0) return;
|
if (capacity <= 0) return;
|
||||||
|
const originAnimals = 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)) {
|
|
||||||
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
|
|
||||||
originAnimals.push({ key, cell, lastFed });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
originAnimals.sort((a, b) => a.lastFed - b.lastFed);
|
|
||||||
|
|
||||||
let fed = 0;
|
let fed = 0;
|
||||||
for (const { key, cell } of originAnimals) {
|
for (const { key, cell } of originAnimals) {
|
||||||
if (fed >= capacity) break;
|
if (fed >= capacity) break;
|
||||||
@@ -105,30 +113,46 @@ export function getFeedingRate(state, _nowUnix) {
|
|||||||
/**
|
/**
|
||||||
* Remove animals and entities that meet death conditions. Increments state.deathCountRecent.
|
* Remove animals and entities that meet death conditions. Increments state.deathCountRecent.
|
||||||
* Causes: not visited, not fed, temperature out of range, biome not allowed,
|
* 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 {import("./types.js").GameState} state
|
||||||
* @param {number} nowUnix
|
* @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 maxVisit = GameConfig.Visitor?.MaxSecondsWithoutVisit ?? 300;
|
||||||
const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120;
|
const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120;
|
||||||
const maxMatureNotPlaced = GameConfig.Nursery?.MaxSecondsMatureNotPlaced ?? 90;
|
|
||||||
const maxReadyNotPlaced = GameConfig.Reception?.MaxSecondsReadyNotPlaced ?? 90;
|
|
||||||
const grid = state.grid;
|
const grid = state.grid;
|
||||||
const cells = grid.cells;
|
const cells = grid.cells;
|
||||||
|
|
||||||
const blocksToRemove = collectAnimalDeathBlocks({ state, grid, cells, nowUnix, maxVisit, maxFood });
|
const blocksToRemove = collectAnimalDeathBlocks({ state, grid, cells, nowUnix, maxVisit, maxFood });
|
||||||
for (const { ox, oy } of blocksToRemove) {
|
for (const { ox, oy } of blocksToRemove) {
|
||||||
const blockKeys = getBlockKeysFromCell(state, ox, oy);
|
const blockKeys = getBlockKeysFromCell(state, ox, oy);
|
||||||
for (const k of blockKeys) delete cells[k];
|
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);
|
const babiesRemoved = filterPendingBabies(state, nowUnix, maxMatureNotPlaced);
|
||||||
if (babiesRemoved > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babiesRemoved;
|
|
||||||
|
|
||||||
const receptionRemoved = filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced);
|
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 }>}
|
* @returns {Array<{ ox: number, oy: number }>}
|
||||||
*/
|
*/
|
||||||
function collectAnimalDeathBlocks(opts) {
|
function collectAnimalDeathBlocks(opts) {
|
||||||
const { grid, cells, nowUnix, maxVisit, maxFood } = opts;
|
const { state, grid, cells, nowUnix, maxVisit, maxFood } = opts;
|
||||||
const blocksToRemove = [];
|
const blocksToRemove = [];
|
||||||
for (const [key, cell] of Object.entries(cells)) {
|
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)) {
|
||||||
@@ -144,7 +168,7 @@ function collectAnimalDeathBlocks(opts) {
|
|||||||
} else {
|
} else {
|
||||||
const def = LootTables.Animals[cell.id];
|
const def = LootTables.Animals[cell.id];
|
||||||
if (def !== null && def !== undefined) {
|
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);
|
if (entry) blocksToRemove.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,26 +176,120 @@ function collectAnimalDeathBlocks(opts) {
|
|||||||
return blocksToRemove;
|
return blocksToRemove;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeDeathBlock(opts) {
|
/** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts
|
||||||
const { key, cell, grid, nowUnix, maxVisit, maxFood, def } = opts;
|
* @returns {boolean}
|
||||||
const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix;
|
*/
|
||||||
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
|
function isBlockTempAndBiomeOk(opts) {
|
||||||
|
const { key, grid, state, def } = opts;
|
||||||
const m = key.match(/^(\d+)_(\d+)$/);
|
const m = key.match(/^(\d+)_(\d+)$/);
|
||||||
if (!m) return null;
|
if (!m) return true;
|
||||||
const ox = Number(m[1]);
|
const ox = Number(m[1]);
|
||||||
const oy = Number(m[2]);
|
const oy = Number(m[2]);
|
||||||
const cellBiome = getDisplayBiome(ox, oy, grid);
|
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 idealTemp = def.idealTemperature ?? 18;
|
||||||
const tolerance = def.temperatureTolerance ?? 5;
|
const tolerance = def.temperatureTolerance ?? 5;
|
||||||
const tempOk = Math.abs(cellTemp - idealTemp) <= tolerance;
|
const tempOk = Math.abs(cellTemp - idealTemp) <= tolerance;
|
||||||
const biomeOk = isAnimalAllowedOnBiome(def.biome, cellBiome);
|
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 visitedOk = nowUnix - lastVisited < maxVisit;
|
||||||
const fedOk = nowUnix - lastFed < maxFood;
|
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;
|
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 {import("./types.js").GameState} state
|
||||||
* @param {number} nowUnix
|
* @param {number} nowUnix
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ import { tickFeeding, checkDeathCauses, getFeedingRate } from "./food.js";
|
|||||||
import { tickReproduction, getReproductionScore } from "./reproduction.js";
|
import { tickReproduction, getReproductionScore } from "./reproduction.js";
|
||||||
import { tickVisitorIncidents } from "./visitor-incidents.js";
|
import { tickVisitorIncidents } from "./visitor-incidents.js";
|
||||||
import { tickSaleListings } from "./trade.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.
|
* Add research points from all research cells. PointsPerTickPerLevel * level per second.
|
||||||
@@ -24,17 +42,8 @@ import { tickSaleListings } from "./trade.js";
|
|||||||
function tickResearch(state, dt) {
|
function tickResearch(state, dt) {
|
||||||
const cfg = GameConfig.Research;
|
const cfg = GameConfig.Research;
|
||||||
if (!cfg || cfg.PointsPerTickPerLevel === null || cfg.PointsPerTickPerLevel === undefined) return;
|
if (!cfg || cfg.PointsPerTickPerLevel === null || cfg.PointsPerTickPerLevel === undefined) return;
|
||||||
const pointsPerLevelPerSecond = cfg.PointsPerTickPerLevel;
|
const total = sumResearchPointsFromCells(state, cfg.PointsPerTickPerLevel, dt);
|
||||||
let total = 0;
|
if (total > 0) state.researchPoints = (state.researchPoints ?? 0) + total;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +104,11 @@ export function startGameLoop(getState, onUpdate, saveStateFn) {
|
|||||||
}
|
}
|
||||||
tickLaboratory(state, nowUnix);
|
tickLaboratory(state, nowUnix);
|
||||||
const { hatched, questEarned } = doOneTick(state, nowUnix, nowMs, dt);
|
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");
|
if (questEarned > 0) playSound("quest");
|
||||||
onUpdate(state, { lastHatched: hatched });
|
onUpdate(state, { lastHatched: hatched });
|
||||||
|
|
||||||
|
|||||||
@@ -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 {import("./types.js").GameState} state
|
||||||
* @param {{ x: number, y: number, nowUnix: number, eventModifiers: { incomeMultiplier: number, mutationBonus: number } }} opts
|
* @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) {
|
export function tryHatchCell(state, opts) {
|
||||||
const { x, y, nowUnix, eventModifiers } = opts;
|
const { x, y, nowUnix, eventModifiers } = opts;
|
||||||
const key = cellKey(x, y);
|
const key = cellKey(x, y);
|
||||||
const cell = state.grid.cells[key];
|
const cell = getEggCellIfReady(state, key, nowUnix);
|
||||||
if (cell === null || cell === undefined || cell.kind !== "egg") return false;
|
if (cell === null) return false;
|
||||||
if (nowUnix < cell.hatchAt) return false;
|
|
||||||
|
|
||||||
const eggDef = LootTables.EggTypes[cell.eggType];
|
const eggDef = LootTables.EggTypes[cell.eggType];
|
||||||
if (eggDef === null || eggDef === undefined) throw new Error("HatchingService: unknown egg type");
|
if (eggDef === null || eggDef === undefined) throw new Error("HatchingService: unknown egg type");
|
||||||
|
const hatchData = getHatchAnimalData({ state, cell, x, y, nowUnix, eventModifiers });
|
||||||
const cellBiome = getCellBiome(state.grid.width, state.grid.height, x, y);
|
if (hatchData === null) return false;
|
||||||
const loot = lootForBiome(cellBiome, eggDef.loot);
|
const { animalData, w, h } = hatchData;
|
||||||
if (loot.length === 0) return false;
|
const [canPlace] = canPlaceMultiCell(state, { originX: x, originY: y, w, h, excludeOriginKey: key });
|
||||||
|
|
||||||
const rng = createSeededRng(cell.seed);
|
|
||||||
const pickedAnimalId = pickId(rng, loot);
|
|
||||||
const animalDef = LootTables.Animals[pickedAnimalId];
|
|
||||||
if (animalDef === null || animalDef === undefined) return false;
|
|
||||||
|
|
||||||
const mutationChance = GameConfig.Mutation.BaseChance + eventModifiers.mutationBonus;
|
|
||||||
let mutationId = "none";
|
|
||||||
if (rng() < mutationChance) mutationId = pickId(rng, getMutationEntries());
|
|
||||||
if (getIncomeMultiplier(mutationId) === undefined) mutationId = "none";
|
|
||||||
|
|
||||||
const w = animalDef.cellsWide ?? 1;
|
|
||||||
const h = animalDef.cellsHigh ?? 1;
|
|
||||||
const [canPlace, _reason] = canPlaceMultiCell(state, { originX: x, originY: y, w, h, excludeOriginKey: key });
|
|
||||||
if (!canPlace) return false;
|
if (!canPlace) return false;
|
||||||
const animalData = buildAnimalCell(pickedAnimalId, mutationId, nowUnix, {
|
|
||||||
cellsWide: w,
|
|
||||||
cellsHigh: h,
|
|
||||||
});
|
|
||||||
fillAnimalBlock(state, x, y, animalData);
|
fillAnimalBlock(state, x, y, animalData);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
36
web/js/income-attractivity.js
Normal file
36
web/js/income-attractivity.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
30
web/js/income-value.js
Normal file
30
web/js/income-value.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
248
web/js/income.js
248
web/js/income.js
@@ -1,35 +1,25 @@
|
|||||||
import { LootTables } from "./loot-tables.js";
|
import { LootTables } from "./loot-tables.js";
|
||||||
import { getIncomeMultiplier } from "./mutation-rules.js";
|
import { getIncomeMultiplier } from "./mutation-rules.js";
|
||||||
import { getLevelMultiplier, getSellValue } from "./economy.js";
|
import { getLevelMultiplier } from "./economy.js";
|
||||||
import { GameConfig } from "./config.js";
|
import { GameConfig } from "./config.js";
|
||||||
import { getPrestigeIncomeMultiplier } from "./prestige.js";
|
import { getPrestigeIncomeMultiplier } from "./prestige.js";
|
||||||
import { isOriginCell } from "./grid-utils.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).
|
* Visitor demand multiplier by time of day (spec visiteur: 08h-10h faible, 10h-16h fort, 16h-18h décroissant, >18h nul).
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {number} timeOfDay 0..24
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
function getTotalAnimalValue(state) {
|
function getVisitorDemandHourMultiplier(timeOfDay) {
|
||||||
let total = 0;
|
const t = timeOfDay % 24;
|
||||||
for (const [key, cell] of Object.entries(state.grid.cells)) {
|
if (t < 8 || t >= 20) return 0;
|
||||||
if (cell.kind !== "animal" || !isOriginCell(key, cell)) {
|
if (t >= 8 && t < 10) return 0.5;
|
||||||
// skip non-origin animals
|
if (t >= 10 && t < 16) return 1;
|
||||||
} else {
|
if (t >= 16 && t < 18) return 0.7;
|
||||||
const animalDef = LootTables.Animals[cell.id];
|
return 0.3;
|
||||||
if (animalDef !== null && animalDef !== undefined) {
|
|
||||||
const mutationMult = getIncomeMultiplier(cell.mutation);
|
|
||||||
total += getSellValue(
|
|
||||||
animalDef.baseIncomePerSecond,
|
|
||||||
cell.level,
|
|
||||||
mutationMult,
|
|
||||||
animalDef.sellFactor
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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
|
* @param {import("./types.js").GameState} state
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
function getStayMultiplier(state) {
|
function getStayMultiplierShopBonus(state) {
|
||||||
let shopBonus = 0;
|
let shopBonus = 0;
|
||||||
for (const cell of Object.values(state.grid.cells)) {
|
for (const cell of Object.values(state.grid.cells)) {
|
||||||
if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") {
|
if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") {
|
||||||
shopBonus += (cell.level ?? 1) * (GameConfig.Visitor.StayMultiplierPerShopLevel ?? 0.15);
|
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();
|
const speciesSet = new Set();
|
||||||
for (const [key, cell] of Object.entries(state.grid.cells)) {
|
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);
|
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);
|
return Math.max(0.5, 1 + shopBonus + diversityBonus);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,69 +153,155 @@ function getVisitorDemand(state, nowUnix) {
|
|||||||
demand *= 1 + cityAttraction;
|
demand *= 1 + cityAttraction;
|
||||||
demand *= 1 + animalValue * animalValueScale;
|
demand *= 1 + animalValue * animalValueScale;
|
||||||
demand *= getStagnationMultiplier(state, nowUnix);
|
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));
|
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).
|
* 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 {import("./types.js").GameState} state
|
||||||
* @param {number} nowUnix
|
* @param {number} nowUnix
|
||||||
*/
|
*/
|
||||||
export function tickVisitorArrivals(state, nowUnix) {
|
export function tickVisitorArrivals(state, nowUnix) {
|
||||||
state.visitorArrivals = state.visitorArrivals ?? [];
|
state.visitorArrivals = state.visitorArrivals ?? [];
|
||||||
const stayDuration = getStayDurationSeconds(state);
|
filterExpiredVisitors(state, nowUnix);
|
||||||
state.visitorArrivals = state.visitorArrivals.filter(
|
if (!isVisitorOpeningHours(state)) return;
|
||||||
(v) => nowUnix < v.arrivedAt + stayDuration
|
|
||||||
);
|
|
||||||
const demand = getVisitorDemand(state, nowUnix);
|
const demand = getVisitorDemand(state, nowUnix);
|
||||||
const cap = getBilleterieCapacity(state);
|
const cap = getBilleterieCapacity(state);
|
||||||
const target = Math.min(cap, demand);
|
const target = Math.min(cap, demand);
|
||||||
const current = state.visitorArrivals.length;
|
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 });
|
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.
|
* 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
|
* @param {import("./types.js").GameState} state
|
||||||
* @returns {{ visitorCount: number, paymentPerVisitor: number }}
|
* @returns {{ visitorCount: number, paymentPerVisitor: number }}
|
||||||
*/
|
*/
|
||||||
function getVisitorParams(state) {
|
function getVisitorParams(state) {
|
||||||
const arrivals = state.visitorArrivals ?? [];
|
const visitorCount = getVisitorCountCapped(state);
|
||||||
let visitorCount = arrivals.length;
|
const paymentPerVisitor = getPaymentPerVisitor(state);
|
||||||
if (visitorCount === 0 && getBilleterieCapacity(state) === 0) {
|
|
||||||
let animalCount = 0;
|
|
||||||
for (const [key, cell] of Object.entries(state.grid.cells)) {
|
|
||||||
if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1;
|
|
||||||
}
|
|
||||||
const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal;
|
|
||||||
const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus;
|
|
||||||
visitorCount = Math.max(0, Math.floor(animalCount * visitorsPerAnimal + plotBonus));
|
|
||||||
}
|
|
||||||
const billeterieCap = getBilleterieCapacity(state);
|
|
||||||
if (billeterieCap > 0 && visitorCount > billeterieCap) visitorCount = billeterieCap;
|
|
||||||
let paymentPerVisitor = GameConfig.Visitor.BasePaymentPerVisitor;
|
|
||||||
let souvenirBonus = 1;
|
|
||||||
let shopCount = 0;
|
|
||||||
for (const cell of Object.values(state.grid.cells)) {
|
|
||||||
if (cell && cell.kind === "souvenirShop") shopCount += (cell.level ?? 1);
|
|
||||||
}
|
|
||||||
if (shopCount > 0) {
|
|
||||||
const bonusPerShop = GameConfig.Visitor.SouvenirShopBonusPerShop ?? 0.2;
|
|
||||||
souvenirBonus = 1 + shopCount * bonusPerShop;
|
|
||||||
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
|
|
||||||
const luxuryShopMult = GameConfig.Visitor.LuxuryShopMultiplier ?? 1;
|
|
||||||
if (luxuryChance > 0 && luxuryShopMult > 1) {
|
|
||||||
souvenirBonus *= 1 + luxuryChance * (luxuryShopMult - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
paymentPerVisitor *= souvenirBonus;
|
|
||||||
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
|
|
||||||
const luxuryEntryMult = GameConfig.Visitor.LuxuryEntryMultiplier ?? 1;
|
|
||||||
if (luxuryChance > 0 && luxuryEntryMult > 1) {
|
|
||||||
paymentPerVisitor *= 1 + luxuryChance * (luxuryEntryMult - 1);
|
|
||||||
}
|
|
||||||
return { visitorCount, paymentPerVisitor };
|
return { visitorCount, paymentPerVisitor };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,28 +315,7 @@ export function getVisitorCount(state) {
|
|||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
export function getAttractivityScore(state) {
|
export function getAttractivityScore(state) {
|
||||||
const value = getTotalAnimalValue(state);
|
const { valueNorm, speciesNorm, rarityNorm, fillNorm } = getAttractivityBase(state);
|
||||||
const originCount = getOriginAnimalCount(state);
|
|
||||||
const grid = state.grid;
|
|
||||||
const cellCount = grid.width * grid.height;
|
|
||||||
const fillRate = cellCount > 0 ? originCount / cellCount : 0;
|
|
||||||
const speciesSet = new Set();
|
|
||||||
let raritySum = 0;
|
|
||||||
for (const [key, cell] of Object.entries(state.grid.cells)) {
|
|
||||||
if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) {
|
|
||||||
// skip
|
|
||||||
} else {
|
|
||||||
speciesSet.add(cell.id);
|
|
||||||
const def = LootTables.Animals[cell.id];
|
|
||||||
if (def) raritySum += def.rarityLevel ?? 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const speciesCount = speciesSet.size;
|
|
||||||
const avgRarity = originCount > 0 ? raritySum / originCount : 0;
|
|
||||||
const valueNorm = value * 0.001;
|
|
||||||
const speciesNorm = speciesCount * 2;
|
|
||||||
const rarityNorm = avgRarity * 0.5;
|
|
||||||
const fillNorm = fillRate * 10;
|
|
||||||
let score = valueNorm + speciesNorm + rarityNorm + fillNorm;
|
let score = valueNorm + speciesNorm + rarityNorm + fillNorm;
|
||||||
const deathPenalty = GameConfig.Visitor?.AttractivityDeathPenalty ?? 0.5;
|
const deathPenalty = GameConfig.Visitor?.AttractivityDeathPenalty ?? 0.5;
|
||||||
const birthBonus = GameConfig.Visitor?.AttractivityBirthBonus ?? 0.2;
|
const birthBonus = GameConfig.Visitor?.AttractivityBirthBonus ?? 0.2;
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ function setMyZooId(id) {
|
|||||||
myZooId = id;
|
myZooId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
async function runBootNoBase(rootEl) {
|
||||||
let base = getApiBase();
|
rootEl.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1>" +
|
||||||
if (!base) {
|
|
||||||
root.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1>" +
|
|
||||||
"<p>Connectez-vous à un serveur pour jouer (compte et sauvegarde en base).</p>" +
|
"<p>Connectez-vous à un serveur pour jouer (compte et sauvegarde en base).</p>" +
|
||||||
"<div style=\"margin-top: 1rem;\"><label for=\"boot-api-url\">URL du serveur</label><input id=\"boot-api-url\" type=\"text\" placeholder=\"https://...\" style=\"display:block;margin-top:4px;width:100%;\" /></div>" +
|
"<div style=\"margin-top: 1rem;\"><label for=\"boot-api-url\">URL du serveur</label><input id=\"boot-api-url\" type=\"text\" placeholder=\"https://...\" style=\"display:block;margin-top:4px;width:100%;\" /></div>" +
|
||||||
"<button id=\"boot-connect\" type=\"button\" style=\"margin-top: 8px;\">Se connecter</button>" +
|
"<button id=\"boot-connect\" type=\"button\" style=\"margin-top: 8px;\">Se connecter</button>" +
|
||||||
@@ -46,26 +44,36 @@ function setMyZooId(id) {
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
root.innerHTML = "";
|
rootEl.innerHTML = "";
|
||||||
base = getApiBase();
|
}
|
||||||
}
|
|
||||||
if (base) {
|
async function runBootWithBase(rootEl) {
|
||||||
root.innerHTML = "<div class=\"boot-panel\"><p>Chargement…</p></div>";
|
rootEl.innerHTML = "<div class=\"boot-panel\"><p>Chargement…</p></div>";
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
state = await bootstrapFromApi(setMyZooId, root);
|
state = await bootstrapFromApi(setMyZooId, rootEl);
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("bootstrapFromApi failed", e);
|
console.error("bootstrapFromApi failed", e);
|
||||||
root.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1><p class=\"boot-err\">Erreur de connexion au serveur.</p><button id=\"boot-retry\" type=\"button\">Réessayer</button></div>";
|
rootEl.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1><p class=\"boot-err\">Erreur de connexion au serveur.</p><button id=\"boot-retry\" type=\"button\">Réessayer</button></div>";
|
||||||
const errP = root.querySelector(".boot-err");
|
const errP = rootEl.querySelector(".boot-err");
|
||||||
if (errP && e && e.message) errP.textContent = e.message;
|
if (errP && e && e.message) errP.textContent = e.message;
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
document.getElementById("boot-retry").addEventListener("click", () => resolve());
|
document.getElementById("boot-retry").addEventListener("click", () => resolve());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
root.innerHTML = "";
|
rootEl.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let base = getApiBase();
|
||||||
|
if (!base) {
|
||||||
|
await runBootNoBase(root);
|
||||||
|
base = getApiBase();
|
||||||
|
}
|
||||||
|
if (base) {
|
||||||
|
await runBootWithBase(root);
|
||||||
}
|
}
|
||||||
if (state) {
|
if (state) {
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,17 @@ import {
|
|||||||
import { GameConfig } from "./config.js";
|
import { GameConfig } from "./config.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All keys that belong to the same animal block as the cell at (x, y). If not an animal or single-cell, returns [cellKey(x,y)].
|
* Origin and dimensions for an animal cell (possibly from originKey). Returns null if not animal.
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").GameState} state
|
||||||
* @param {number} x
|
* @param {import("./types.js").Cell} cell
|
||||||
* @param {number} y
|
* @param {string} key
|
||||||
* @returns {string[]}
|
* @param {{ x: number, y: number }} pos
|
||||||
|
* @returns {{ ox: number, oy: number, w: number, h: number } | null}
|
||||||
*/
|
*/
|
||||||
export function getBlockKeysFromCell(state, x, y) {
|
function getAnimalBlockOrigin(state, cell, key, pos) {
|
||||||
const key = cellKey(x, y);
|
if (cell === null || cell === undefined || cell.kind !== "animal") return null;
|
||||||
const cell = state.grid.cells[key];
|
let ox = pos.x;
|
||||||
if (cell === null || cell === undefined || cell.kind !== "animal") return [key];
|
let oy = pos.y;
|
||||||
let ox = x;
|
|
||||||
let oy = y;
|
|
||||||
let w = cell.cellsWide ?? 1;
|
let w = cell.cellsWide ?? 1;
|
||||||
let h = cell.cellsHigh ?? 1;
|
let h = cell.cellsHigh ?? 1;
|
||||||
if (cell.originKey !== null && cell.originKey !== undefined) {
|
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<string>}
|
||||||
|
*/
|
||||||
|
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) {
|
export function canPlaceMultiCell(state, opts) {
|
||||||
const { originX, originY, w, h, excludeOriginKey } = opts;
|
const { originX, originY, w, h, excludeOriginKey } = opts;
|
||||||
const excludeSet = new Set();
|
const excludeSet = buildExcludeSet(state, excludeOriginKey);
|
||||||
if (excludeOriginKey !== null && excludeOriginKey !== undefined) {
|
|
||||||
const orig = state.grid.cells[excludeOriginKey];
|
|
||||||
if (orig && orig.kind === "animal") {
|
|
||||||
const [ox, oy] = excludeOriginKey.split("_").map(Number);
|
|
||||||
const ow = orig.cellsWide ?? 1;
|
|
||||||
const oh = orig.cellsHigh ?? 1;
|
|
||||||
getBlockKeys(ox, oy, ow, oh).forEach((k) => excludeSet.add(k));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let dy = 0; dy < h; dy++) {
|
for (let dy = 0; dy < h; dy++) {
|
||||||
for (let dx = 0; dx < w; dx++) {
|
for (let dx = 0; dx < w; dx++) {
|
||||||
const nx = originX + dx;
|
const nx = originX + dx;
|
||||||
@@ -124,6 +148,25 @@ export function placeEgg(state, opts) {
|
|||||||
return [true, undefined];
|
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.
|
* Déplace le contenu d'une case vers une case vide (œuf ou animal). For multi-cell animals, moves the whole block.
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").GameState} state
|
||||||
@@ -139,29 +182,9 @@ export function moveCell(state, opts) {
|
|||||||
if (source === null || source === undefined) return [false, "NoSource"];
|
if (source === null || source === undefined) return [false, "NoSource"];
|
||||||
if (source.kind === "animal") {
|
if (source.kind === "animal") {
|
||||||
const blockKeys = getBlockKeysFromCell(state, fromX, fromY);
|
const blockKeys = getBlockKeysFromCell(state, fromX, fromY);
|
||||||
let ox = fromX;
|
const origin = getAnimalBlockOrigin(state, source, fromKey, { x: fromX, y: fromY });
|
||||||
let oy = fromY;
|
if (origin === null) return [false, "NoSource"];
|
||||||
let w = source.cellsWide ?? 1;
|
return moveAnimalBlock(state, { blockKeys, ox: origin.ox, oy: origin.oy, toX, toY, source });
|
||||||
let h = source.cellsHigh ?? 1;
|
|
||||||
if (source.originKey !== null && source.originKey !== undefined) {
|
|
||||||
const m = source.originKey.match(/^(\d+)_(\d+)$/);
|
|
||||||
if (m) {
|
|
||||||
ox = Number(m[1]);
|
|
||||||
oy = Number(m[2]);
|
|
||||||
const origin = state.grid.cells[source.originKey];
|
|
||||||
if (origin && origin.kind === "animal") {
|
|
||||||
w = origin.cellsWide ?? 1;
|
|
||||||
h = origin.cellsHigh ?? 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const originKey = cellKey(ox, oy);
|
|
||||||
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h, excludeOriginKey: originKey });
|
|
||||||
if (!ok) return [false, reason];
|
|
||||||
const animalData = { ...source, originKey: toKey, cellsWide: w, cellsHigh: h };
|
|
||||||
for (const k of blockKeys) delete state.grid.cells[k];
|
|
||||||
fillAnimalBlock(state, toX, toY, animalData);
|
|
||||||
return [true, undefined];
|
|
||||||
}
|
}
|
||||||
const [ok, reason] = canPlace(state, toX, toY);
|
const [ok, reason] = canPlace(state, toX, toY);
|
||||||
if (!ok) return [false, reason];
|
if (!ok) return [false, reason];
|
||||||
|
|||||||
@@ -18,15 +18,6 @@ function daySeed(dateKey) {
|
|||||||
return h;
|
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
|
* @param {import("./types.js").GameState} state
|
||||||
* @returns {import("./types.js").Quest[]}
|
* @returns {import("./types.js").Quest[]}
|
||||||
@@ -54,7 +45,9 @@ export function generateDailyQuests(state) {
|
|||||||
return state.quests;
|
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) {
|
function getQuestProgress(state) {
|
||||||
const s = state.stats ?? { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, coinsEarned: 0 };
|
const s = state.stats ?? { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, coinsEarned: 0 };
|
||||||
return {
|
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
|
* @param {import("./types.js").GameState} state
|
||||||
* @returns {number} coins awarded from completed quests this tick
|
* @returns {number} coins awarded from completed quests this tick
|
||||||
@@ -75,16 +76,10 @@ export function tickQuests(state) {
|
|||||||
const progress = getQuestProgress(state);
|
const progress = getQuestProgress(state);
|
||||||
let earned = 0;
|
let earned = 0;
|
||||||
for (const q of state.quests ?? []) {
|
for (const q of state.quests ?? []) {
|
||||||
if (q.done) {
|
if (!q.done) {
|
||||||
// already done
|
const progressKey = QUEST_PROGRESS_KEYS[q.descriptionKey];
|
||||||
} else {
|
const current = progressKey !== undefined ? (progress[progressKey] ?? 0) : null;
|
||||||
let current = null;
|
if (current !== null) {
|
||||||
if (q.descriptionKey === "questPlaceEggs") current = progress.eggsPlaced;
|
|
||||||
else if (q.descriptionKey === "questSellAnimals") current = progress.animalsSold;
|
|
||||||
else if (q.descriptionKey === "questUpgradeConveyor") current = progress.conveyorUpgrades;
|
|
||||||
else if (q.descriptionKey === "questUpgradePlot") current = progress.plotUpgrades;
|
|
||||||
else if (q.descriptionKey === "questEarnCoins") current = progress.coinsEarned ?? 0;
|
|
||||||
if (current !== null && current !== undefined) {
|
|
||||||
q.current = Math.min(current, q.target);
|
q.current = Math.min(current, q.target);
|
||||||
if (q.current >= q.target) {
|
if (q.current >= q.target) {
|
||||||
q.done = true;
|
q.done = true;
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ import { cellKey, isOriginCell } from "./grid-utils.js";
|
|||||||
import { getBlockKeysFromCell } from "./placement.js";
|
import { getBlockKeysFromCell } from "./placement.js";
|
||||||
import { getDisplayBiome, getDisplayTemperature } from "./biome-rules.js";
|
import { getDisplayBiome, getDisplayTemperature } from "./biome-rules.js";
|
||||||
import { addPendingBaby } from "./zoo.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.
|
* 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.
|
* Collect origin animal entries (key, animalId, fromOtherZoo) from grid.
|
||||||
* Returns unique pairs with keyA < keyB lexicographically.
|
|
||||||
* @param {import("./types.js").GameState} state
|
* @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 cells = state.grid.cells;
|
||||||
const origins = [];
|
const origins = [];
|
||||||
for (const [key, cell] of Object.entries(cells)) {
|
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];
|
const def = LootTables.Animals[cell.id];
|
||||||
if (def !== null && def !== undefined) {
|
if (def !== null && def !== undefined) {
|
||||||
origins.push({
|
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 = [];
|
const pairs = [];
|
||||||
for (let i = 0; i < origins.length; i++) {
|
for (let i = 0; i < origins.length; i++) {
|
||||||
for (let j = i + 1; j < origins.length; j++) {
|
for (let j = i + 1; j < origins.length; j++) {
|
||||||
@@ -116,6 +140,17 @@ export function findReproductionPairs(state) {
|
|||||||
return pairs;
|
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.
|
* Unique pair key for deduplication.
|
||||||
* @param {string} keyA
|
* @param {string} keyA
|
||||||
@@ -196,13 +231,16 @@ function addNewPairsToTimers(state, nowUnix, timers, existingSet) {
|
|||||||
const m1 = keyA.match(/^(\d+)_(\d+)$/);
|
const m1 = keyA.match(/^(\d+)_(\d+)$/);
|
||||||
const m2 = keyB.match(/^(\d+)_(\d+)$/);
|
const m2 = keyB.match(/^(\d+)_(\d+)$/);
|
||||||
if (m1 && m2) {
|
if (m1 && m2) {
|
||||||
|
const season = getCurrentSeason(state);
|
||||||
|
const seasonTempMod = getSeasonTemperatureModifier(season);
|
||||||
const biome1 = getDisplayBiome(Number(m1[1]), Number(m1[2]), grid);
|
const biome1 = getDisplayBiome(Number(m1[1]), Number(m1[2]), grid);
|
||||||
const biome2 = getDisplayBiome(Number(m2[1]), Number(m2[2]), grid);
|
const biome2 = getDisplayBiome(Number(m2[1]), Number(m2[2]), grid);
|
||||||
const temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid);
|
const temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid) + seasonTempMod;
|
||||||
const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid);
|
const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid) + seasonTempMod;
|
||||||
const biomeFactor = (getBiomeReproductionFactor(def, biome1) + getBiomeReproductionFactor(def, biome2)) / 2;
|
const biomeFactor = (getBiomeReproductionFactor(def, biome1) + getBiomeReproductionFactor(def, biome2)) / 2;
|
||||||
const tempFactor = (getTemperatureFactor(def, temp1) + getTemperatureFactor(def, temp2)) / 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);
|
const delay = Math.max(5, baseSeconds / factor);
|
||||||
timers.push({ keyA, keyB, animalId, dueAt: nowUnix + Math.floor(delay) });
|
timers.push({ keyA, keyB, animalId, dueAt: nowUnix + Math.floor(delay) });
|
||||||
existingSet.add(pk);
|
existingSet.add(pk);
|
||||||
|
|||||||
79
web/js/seasons.js
Normal file
79
web/js/seasons.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -54,14 +54,19 @@ function buildDefaultCells() {
|
|||||||
return buildDefaultRow1Cells();
|
return buildDefaultRow1Cells();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultStats() {
|
||||||
|
return { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
function buildStatePayload(width, height, worldZoos, cells) {
|
function buildStatePayload(width, height, worldZoos, cells) {
|
||||||
|
const grid = { width, height, cells };
|
||||||
return {
|
return {
|
||||||
version: GameConfig.StateVersion,
|
version: GameConfig.StateVersion,
|
||||||
coins: 200,
|
coins: 200,
|
||||||
conveyorLevel: 1,
|
conveyorLevel: 1,
|
||||||
plotLevel: 1,
|
plotLevel: 1,
|
||||||
truckLevel: 1,
|
truckLevel: 1,
|
||||||
grid: { width, height, cells },
|
grid,
|
||||||
pendingEggTokens: [],
|
pendingEggTokens: [],
|
||||||
nextTokenId: 1,
|
nextTokenId: 1,
|
||||||
conveyorOffers: [],
|
conveyorOffers: [],
|
||||||
@@ -73,15 +78,14 @@ function buildStatePayload(width, height, worldZoos, cells) {
|
|||||||
laboratoryOffer: null,
|
laboratoryOffer: null,
|
||||||
prestigeLevel: 0,
|
prestigeLevel: 0,
|
||||||
timeOfDay: 6,
|
timeOfDay: 6,
|
||||||
|
gameDayTotal: 0,
|
||||||
|
lastSeason: "spring",
|
||||||
weather: "sun",
|
weather: "sun",
|
||||||
lastWeatherChangeAt: 0,
|
lastWeatherChangeAt: 0,
|
||||||
quests: [],
|
quests: [],
|
||||||
lastQuestDay: "",
|
lastQuestDay: "",
|
||||||
stats: { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 },
|
stats: getDefaultStats(),
|
||||||
mapZoom: 1,
|
mapZoom: 1, mapPanX: 0, mapPanY: 0, worldMapLevel: 1,
|
||||||
mapPanX: 0,
|
|
||||||
mapPanY: 0,
|
|
||||||
worldMapLevel: 1,
|
|
||||||
autoMode: false,
|
autoMode: false,
|
||||||
autoModeProfile: "balanced",
|
autoModeProfile: "balanced",
|
||||||
researchPoints: 0,
|
researchPoints: 0,
|
||||||
@@ -145,16 +149,22 @@ function applyLoadStateWorldZoos(data) {
|
|||||||
data.worldZoos = [...GameConfig.WorldMap.Zoos];
|
data.worldZoos = [...GameConfig.WorldMap.Zoos];
|
||||||
}
|
}
|
||||||
if (data.worldZoos !== null && data.worldZoos !== undefined && Array.isArray(data.worldZoos)) {
|
if (data.worldZoos !== null && data.worldZoos !== undefined && Array.isArray(data.worldZoos)) {
|
||||||
|
applyWorldZoosWeightsAndBots(data);
|
||||||
|
}
|
||||||
|
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();
|
const keys = getColorNames();
|
||||||
data.worldZoos = data.worldZoos.map((z, _i) => ({
|
data.worldZoos = data.worldZoos.map((z) => ({
|
||||||
...z,
|
...z,
|
||||||
animalWeights: z.animalWeights && keys.some((k) => k in (z.animalWeights ?? {}))
|
animalWeights: z.animalWeights && keys.some((k) => k in (z.animalWeights ?? {}))
|
||||||
? z.animalWeights
|
? z.animalWeights
|
||||||
: normalizeZooWeights(z.animalWeights),
|
: normalizeZooWeights(z.animalWeights),
|
||||||
}));
|
}));
|
||||||
data.worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player"));
|
data.worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player"));
|
||||||
}
|
|
||||||
if (data.worldZoos === null || data.worldZoos === undefined) data.worldZoos = [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set data[key] to defaultVal when data[key] is null or undefined. defaultVal may be a function (called for value). */
|
/** 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],
|
["nextTokenId", 1],
|
||||||
["prestigeLevel", 0],
|
["prestigeLevel", 0],
|
||||||
["timeOfDay", 6],
|
["timeOfDay", 6],
|
||||||
|
["gameDayTotal", 0],
|
||||||
|
["lastSeason", "spring"],
|
||||||
["weather", "sun"],
|
["weather", "sun"],
|
||||||
["lastWeatherChangeAt", 0],
|
["lastWeatherChangeAt", 0],
|
||||||
["quests", []],
|
["quests", []],
|
||||||
@@ -211,15 +223,24 @@ function applyLoadStateLegacyCells(data) {
|
|||||||
if (c12 && (c12.kind === "plotUpgrade" || c12.kind === "worldMapUpgrade")) delete data.grid.cells["1_2"];
|
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) {
|
function normalizeLoadedCells(cells) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
for (const key of Object.keys(cells)) {
|
for (const key of Object.keys(cells)) {
|
||||||
const cell = cells[key];
|
const cell = cells[key];
|
||||||
if (cell) {
|
if (cell) {
|
||||||
if (cell.kind === "animal" && cell.id && !LootTables.Animals[cell.id]) cell.id = "c0_r0";
|
if (cell.kind === "animal") normalizeOneAnimalCell(cell, now);
|
||||||
if (cell.kind === "animal" && (cell.lastVisitedAt === null || cell.lastVisitedAt === undefined)) cell.lastVisitedAt = now;
|
if (cell.kind === "egg") normalizeOneEggCell(cell);
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export const weatherLabel = { sun: "Ensoleillé", cloudy: "Nuageux", rain: "Plui
|
|||||||
export const prestigeLabel = "Prestige (reset avec bonus permanent)";
|
export const prestigeLabel = "Prestige (reset avec bonus permanent)";
|
||||||
export const prestigeButton = "Réinitialiser (Prestige +%d)";
|
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 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 visitorsLabel = "Visiteurs";
|
||||||
export const musicLabel = "Musique";
|
export const musicLabel = "Musique";
|
||||||
export const incidentLabel = {
|
export const incidentLabel = {
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { GameConfig } from "./config.js";
|
|||||||
export function tickTime(state, dtWallSeconds) {
|
export function tickTime(state, dtWallSeconds) {
|
||||||
const dayLength = GameConfig.Time.DayLengthSeconds;
|
const dayLength = GameConfig.Time.DayLengthSeconds;
|
||||||
const phase = (state.timeOfDay ?? 6) + (dtWallSeconds * 24) / dayLength;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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).
|
* 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 {import("./types.js").GameState} state
|
||||||
* @param {number} nowUnix
|
* @param {number} nowUnix
|
||||||
*/
|
*/
|
||||||
@@ -99,24 +99,29 @@ export function tickSaleListings(state, nowUnix) {
|
|||||||
const listings = state.saleListings ?? [];
|
const listings = state.saleListings ?? [];
|
||||||
const kept = [];
|
const kept = [];
|
||||||
let babyDeaths = 0;
|
let babyDeaths = 0;
|
||||||
|
let adultDeaths = 0;
|
||||||
for (const listing of listings) {
|
for (const listing of listings) {
|
||||||
if (nowUnix < listing.endAt) {
|
if (nowUnix < listing.endAt) {
|
||||||
kept.push(listing);
|
kept.push(listing);
|
||||||
} else if (listing.isBaby) {
|
} else if (listing.isBaby) {
|
||||||
babyDeaths += 1;
|
babyDeaths += 1;
|
||||||
|
} else {
|
||||||
|
adultDeaths += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.saleListings = kept;
|
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 {import("./types.js").GameState} state
|
||||||
* @param {number} x
|
* @param {number} x
|
||||||
* @param {number} y
|
* @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 key = cellKey(x, y);
|
||||||
const cell = state.grid.cells[key];
|
const cell = state.grid.cells[key];
|
||||||
if (cell === null || cell === undefined || cell.kind !== "animal") return [false, "NoAnimal"];
|
if (cell === null || cell === undefined || cell.kind !== "animal") return [false, "NoAnimal"];
|
||||||
@@ -133,6 +138,19 @@ export function sellAnimalToNpc(state, x, y) {
|
|||||||
mutationMultiplier,
|
mutationMultiplier,
|
||||||
animalDef.sellFactor
|
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];
|
for (const k of blockKeys) delete state.grid.cells[k];
|
||||||
state.coins += sellValue;
|
state.coins += sellValue;
|
||||||
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
|
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
* laboratoryOffer?: { eggType: string, price: number, endAt: number } | null,
|
* laboratoryOffer?: { eggType: string, price: number, endAt: number } | null,
|
||||||
* prestigeLevel?: number,
|
* prestigeLevel?: number,
|
||||||
* timeOfDay?: number,
|
* timeOfDay?: number,
|
||||||
|
* gameDayTotal?: number,
|
||||||
|
* lastSeason?: string,
|
||||||
|
* seasonChangeMessage?: string,
|
||||||
* weather?: string,
|
* weather?: string,
|
||||||
* lastWeatherChangeAt?: number,
|
* lastWeatherChangeAt?: number,
|
||||||
* quests?: Quest[],
|
* quests?: Quest[],
|
||||||
@@ -72,7 +75,6 @@
|
|||||||
* feedingRate?: number,
|
* feedingRate?: number,
|
||||||
* reproductionScore?: number,
|
* reproductionScore?: number,
|
||||||
* attractivityScore?: number,
|
* attractivityScore?: number,
|
||||||
* attractivityScore?: number,
|
|
||||||
* reproductionTimers?: Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>,
|
* reproductionTimers?: Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>,
|
||||||
* visitorArrivals?: VisitorEntry[],
|
* visitorArrivals?: VisitorEntry[],
|
||||||
* attractivityBonusFromIncidents?: number,
|
* attractivityBonusFromIncidents?: number,
|
||||||
|
|||||||
273
web/js/ui-grid-cells.js
Normal file
273
web/js/ui-grid-cells.js
Normal file
@@ -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<string, string> }} 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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
divEl.innerHTML = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>${arrow}`;
|
||||||
|
if (isReady) divEl.dataset.receptionCellKey = cellKey;
|
||||||
|
} else {
|
||||||
|
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
divEl.innerHTML = `<span class="cell-emoji">📥</span><span class="cell-label">Accueil ${level}</span>${arrow}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ state: import("./types.js").GameState, animalEmoji: Record<string, string> }} 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 = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>`;
|
||||||
|
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 = `<span class="cell-emoji">${EGG_EMOJI}</span><span class="cell-label">${label}</span>`;
|
||||||
|
divEl.dataset.tokenId = String(nurseryCell.tokenId);
|
||||||
|
} else {
|
||||||
|
const arrow = canUpgradeNursery ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
divEl.innerHTML = `<span class="cell-emoji">🐣</span><span class="cell-label">Nurserie ${nurseryLevel}</span>${arrow}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ state: import("./types.js").GameState, animalEmoji: Record<string, string> }} 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 = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 = `<button type="button" class="cell-choice-btn" data-choice="nursery" ${canNursery ? "" : "disabled"}>🐣 Nurserie (${nurseryCost})</button><button type="button" class="cell-choice-btn" data-choice="shop" ${canShop ? "" : "disabled"}>🛒 Boutique (${shopCost})</button><button type="button" class="cell-choice-btn" data-choice="research" ${canResearch ? "" : "disabled"}>🔬 Recherche (${researchCost})</button><button type="button" class="cell-choice-btn" data-choice="billeterie" ${canBilleterie ? "" : "disabled"}>🎫 Billeterie (${billeterieCost})</button><button type="button" class="cell-choice-btn" data-choice="food" ${canFood ? "" : "disabled"}>🥗 Nourriture (${foodCost})</button><button type="button" class="cell-choice-btn" data-choice="reception" ${canReception ? "" : "disabled"}>📥 Accueil (${receptionCost})</button><button type="button" class="cell-choice-btn" data-choice="biomeColor" ${canBiomeColor ? "" : "disabled"}>🎨 Couleur (${biomeColorCost})</button><button type="button" class="cell-choice-btn" data-choice="biomeTemp" ${canBiomeTemp ? "" : "disabled"}>🌡️ Temp (${biomeTempCost})</button>`;
|
||||||
|
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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
div.innerHTML = `<span class="cell-emoji">🏫</span><span class="cell-label">École ${cell.level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
div.innerHTML = `<span class="cell-emoji">🛒</span><span class="cell-label">Boutique ${shopLevel}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
div.innerHTML = `<span class="cell-emoji">🔬</span><span class="cell-label">Recherche ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
div.innerHTML = `<span class="cell-emoji">🎫</span><span class="cell-label">Billeterie ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
div.innerHTML = `<span class="cell-emoji">🥗</span><span class="cell-label">Nourriture ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
div.innerHTML = `<span class="cell-emoji">🎨</span><span class="cell-label">Couleur ${level}</span>${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 ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
|
||||||
|
div.innerHTML = `<span class="cell-emoji">🌡️</span><span class="cell-label">Temp ${level}</span>${arrow}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillEggCell(ctx, div, cell) {
|
||||||
|
div.classList.add("egg", "cell-draggable");
|
||||||
|
div.draggable = true;
|
||||||
|
const label = eggTypeLabel[cell.eggType] ?? cell.eggType;
|
||||||
|
div.innerHTML = `<span class="cell-emoji">${EGG_EMOJI}</span><span class="cell-label">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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<string, string> }} 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 };
|
||||||
136
web/js/ui-grid-drag.js
Normal file
136
web/js/ui-grid-drag.js
Normal file
@@ -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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
257
web/js/ui-grid-handlers.js
Normal file
257
web/js/ui-grid-handlers.js
Normal file
@@ -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 };
|
||||||
47
web/js/ui-grid.js
Normal file
47
web/js/ui-grid.js
Normal file
@@ -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<string, string> }} 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<string, string> }} 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
web/js/ui-helpers.js
Normal file
36
web/js/ui-helpers.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
166
web/js/ui-render-dom-drops.js
Normal file
166
web/js/ui-render-dom-drops.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
224
web/js/ui-render-dom-panels.js
Normal file
224
web/js/ui-render-dom-panels.js
Normal file
@@ -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 = "<span class=\"world-map-upgrade-zone-icon\" aria-hidden=\"true\">🗺️</span><span class=\"world-map-upgrade-zone-label\">Agrandir carte</span><span class=\"world-map-upgrade-zone-cost\" aria-hidden=\"true\"></span><span class=\"world-map-upgrade-zone-arrow\" aria-hidden=\"true\">▲</span>";
|
||||||
|
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 = "<span class=\"world-map-truck-drop-icon\" aria-hidden=\"true\">🚚</span><span class=\"world-map-truck-drop-label\">Acheter œuf</span>";
|
||||||
|
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 = "<span class=\"plot-upgrade-zone-icon\" aria-hidden=\"true\">📐</span><span class=\"plot-upgrade-zone-label\">Agrandir zoo</span><span class=\"plot-upgrade-zone-arrow\" aria-hidden=\"true\">▲</span>";
|
||||||
|
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 = "<span class=\"sell-zone-icon\" aria-hidden=\"true\">🚚</span><span class=\"sell-zone-label\">" + sellZoneShortLabel + "</span><span class=\"sell-zone-upgrade-arrow\" aria-hidden=\"true\">▲</span>";
|
||||||
|
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();
|
||||||
|
}
|
||||||
274
web/js/ui-render-dom.js
Normal file
274
web/js/ui-render-dom.js
Normal file
@@ -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 `<div class="quest-item ${q.done ? "done" : ""}">${text} : ${q.current}/${q.target}${done}</div>`;
|
||||||
|
}).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 });
|
||||||
|
}
|
||||||
95
web/js/ui-render-gamebar-picker.js
Normal file
95
web/js/ui-render-gamebar-picker.js
Normal file
@@ -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<import("./types.js").GameState>) => 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<import("./types.js").GameState>) => 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<import("./types.js").GameState>) => 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);
|
||||||
|
}
|
||||||
271
web/js/ui-render-gamebar.js
Normal file
271
web/js/ui-render-gamebar.js
Normal file
@@ -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<import("./types.js").GameState>) => 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<import("./types.js").GameState>) => 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
33
web/js/ui-world-map-cities.js
Normal file
33
web/js/ui-world-map-cities.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
web/js/ui-world-map-sales.js
Normal file
162
web/js/ui-world-map-sales.js
Normal file
@@ -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<string, string> }} 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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${s.initial_price} 💰</span>`;
|
||||||
|
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<string, string> }} 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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span>`;
|
||||||
|
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<string, string> }} 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<string, string> }} 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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${s.initial_price} 💰</span>`;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
web/js/ui-world-map-trucks.js
Normal file
213
web/js/ui-world-map-trucks.js
Normal file
@@ -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<string, number> }} 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<string, number> }} 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 = `<span class="offer-emoji">${EGG_EMOJI}</span><span class="offer-label">${name}</span><span class="offer-price">${labOffer.price} pièces</span>`;
|
||||||
|
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<string, number> }} 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<import("./types.js").WorldZoo>} 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<import("./types.js").WorldZoo>} 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<import("./types.js").WorldZoo>} 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<import("./types.js").WorldZoo>} 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<import("./types.js").WorldZoo>} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
web/js/ui-world-map.js
Normal file
242
web/js/ui-world-map.js
Normal file
@@ -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<string, string> }} 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<string, string> }} 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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${listing.price} 💰</span>`;
|
||||||
|
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 = `<span class="offer-emoji">${EGG_EMOJI}</span><span class="offer-label">${name}</span><span class="offer-price">${offer.price} pièces</span>`;
|
||||||
|
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<string, string> }} 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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">Bébé ${name}</span><span class="offer-price">${babyOffer.price}</span>`;
|
||||||
|
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 = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${name}</span><span class="offer-price">${animalOffer.price}</span>`;
|
||||||
|
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<string, string>, 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<string, string> }} ctx
|
||||||
|
* @param {import("./types.js").WorldZoo} zoo
|
||||||
|
* @param {Array<{ animalId: string, isBaby: boolean, price: number }>} zooListingsForPlayer
|
||||||
|
* @param {Array<import("./conveyor.js").ConveyorOffer>} 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<string, string> }} ctx
|
||||||
|
* @param {Array<import("./types.js").WorldZoo>} zoos
|
||||||
|
* @param {Array<import("./conveyor.js").ConveyorOffer>} 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<string, string>, pendingTokenByEggType: Record<string, number> }} 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";
|
||||||
1605
web/js/ui.js
1605
web/js/ui.js
File diff suppressed because it is too large
Load Diff
@@ -12,25 +12,93 @@ export const INCIDENT_TYPES = ["thirst", "bin", "bench", "animalFar", "photo"];
|
|||||||
export const INCIDENT_EMOJI = { thirst: "💧", bin: "🗑️", bench: "🪑", animalFar: "🦌", photo: "📷" };
|
export const INCIDENT_EMOJI = { thirst: "💧", bin: "🗑️", bench: "🪑", animalFar: "🦌", photo: "📷" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when player is in a wait phase (truck moving, sale pending validation, etc.).
|
* True when truck (egg or sale) is in progress.
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").GameState} state
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function isInWaitPhase(state) {
|
function hasTruckWait(state) {
|
||||||
if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true;
|
if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true;
|
||||||
if (state.truckSale && state.truckSale.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;
|
const api = state.salesFromApi;
|
||||||
if (api && api.asBuyerUndelivered && api.asBuyerUndelivered.length > 0) {
|
if (!api || !api.asBuyerUndelivered || api.asBuyerUndelivered.length === 0) return false;
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
for (const s of api.asBuyerUndelivered) {
|
for (const s of api.asBuyerUndelivered) {
|
||||||
const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0;
|
const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0;
|
||||||
const pending = (s.status === "sold" || s.status === "validated") && validatedAtMs > nowMs;
|
const pending = (s.status === "sold" || s.status === "validated") && validatedAtMs > nowMs;
|
||||||
if (pending) return true;
|
if (pending) return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false;
|
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) {
|
||||||
|
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 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn and expire incidents. Call after tickVisitorArrivals.
|
* Spawn and expire incidents. Call after tickVisitorArrivals.
|
||||||
* @param {import("./types.js").GameState} state
|
* @param {import("./types.js").GameState} state
|
||||||
@@ -38,30 +106,30 @@ export function isInWaitPhase(state) {
|
|||||||
*/
|
*/
|
||||||
export function tickVisitorIncidents(state, nowUnix) {
|
export function tickVisitorIncidents(state, nowUnix) {
|
||||||
const arrivals = state.visitorArrivals ?? [];
|
const arrivals = state.visitorArrivals ?? [];
|
||||||
const cfg = GameConfig.Visitor;
|
const { baseChance, waitMult, timeoutSec, penalty } = getIncidentConfig();
|
||||||
const baseChance = cfg?.IncidentChanceBase ?? 0.002;
|
|
||||||
const waitMult = cfg?.IncidentChanceWaitMultiplier ?? 4;
|
|
||||||
const timeoutSec = cfg?.IncidentTimeoutSeconds ?? 45;
|
|
||||||
const penalty = cfg?.IncidentUnresolvedAttractivityPenalty ?? 0.2;
|
|
||||||
const inWait = isInWaitPhase(state);
|
const inWait = isInWaitPhase(state);
|
||||||
const chance = inWait ? baseChance * waitMult : baseChance;
|
const chance = inWait ? baseChance * waitMult : baseChance;
|
||||||
const toRemove = [];
|
const toRemove = expireIncidents(arrivals, { nowUnix, timeoutSec, penalty, stateRef: state });
|
||||||
|
|
||||||
for (let i = 0; i < arrivals.length; i++) {
|
|
||||||
const v = arrivals[i];
|
|
||||||
if (v.incidentType !== null && v.incidentType !== undefined) {
|
|
||||||
if (nowUnix - (v.incidentSince ?? nowUnix) >= timeoutSec) {
|
|
||||||
state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) - penalty;
|
|
||||||
toRemove.push(i);
|
|
||||||
}
|
|
||||||
} else if (Math.random() < chance) {
|
|
||||||
v.incidentType = INCIDENT_TYPES[Math.floor(Math.random() * INCIDENT_TYPES.length)];
|
|
||||||
v.incidentSince = nowUnix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let r = toRemove.length - 1; r >= 0; r--) {
|
for (let r = toRemove.length - 1; r >= 0; r--) {
|
||||||
arrivals.splice(toRemove[r], 1);
|
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 arrivals = state.visitorArrivals ?? [];
|
||||||
const v = arrivals[visitorIndex];
|
const v = arrivals[visitorIndex];
|
||||||
if (!v || (v.incidentType === null || v.incidentType === undefined)) return false;
|
if (!v || (v.incidentType === null || v.incidentType === undefined)) return false;
|
||||||
const cfg = GameConfig.Visitor;
|
applyResolveBonus(state, v);
|
||||||
const coinBonus = cfg?.IncidentResolveCoinBonus ?? 8;
|
|
||||||
const attractivityBonus = cfg?.IncidentResolveAttractivityBonus ?? 0.15;
|
|
||||||
state.coins += coinBonus;
|
|
||||||
state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) + attractivityBonus;
|
|
||||||
if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + coinBonus;
|
|
||||||
delete v.incidentType;
|
|
||||||
delete v.incidentSince;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
90
web/js/zoo-placement.js
Normal file
90
web/js/zoo-placement.js
Normal file
@@ -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];
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { LootTables, getRarityHatchMultiplierForEggType } from "./loot-tables.js
|
|||||||
import { plotSizeFromLevel } from "./grid-utils.js";
|
import { plotSizeFromLevel } from "./grid-utils.js";
|
||||||
import { getPlotUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js";
|
import { getPlotUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js";
|
||||||
import { findOffer } from "./conveyor.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";
|
import { getNurseryCellKeysOrdered, getFreeNurseryCellKey } from "./zoo-nursery.js";
|
||||||
|
|
||||||
export { getNurseryCellKeysOrdered, getFreeNurseryCellKey };
|
export { getNurseryCellKeysOrdered, getFreeNurseryCellKey };
|
||||||
@@ -125,28 +126,9 @@ export function tryBuyAnimal(state, animalId, price) {
|
|||||||
*/
|
*/
|
||||||
export function placeMatureBabyOnCell(state, opts) {
|
export function placeMatureBabyOnCell(state, opts) {
|
||||||
const { nurseryCellKey, toX, toY, nowUnix } = opts;
|
const { nurseryCellKey, toX, toY, nowUnix } = opts;
|
||||||
const baby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === nurseryCellKey);
|
const [ok, result] = getMatureBabyPlacementData(state, opts);
|
||||||
if (baby === null || baby === undefined) return [false, "NoBaby"];
|
if (!ok) return [false, result];
|
||||||
if (nowUnix < baby.readyAt) return [false, "BabyNotReady"];
|
fillAnimalBlock(state, toX, toY, result);
|
||||||
const def = LootTables.Animals[baby.animalId];
|
|
||||||
if (def === null || def === undefined) return [false, "UnknownAnimal"];
|
|
||||||
const w = def.cellsWide ?? 1;
|
|
||||||
const h = def.cellsHigh ?? 1;
|
|
||||||
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
|
|
||||||
if (!ok) return [false, reason];
|
|
||||||
const animalData = {
|
|
||||||
kind: "animal",
|
|
||||||
id: baby.animalId,
|
|
||||||
mutation: "none",
|
|
||||||
level: 1,
|
|
||||||
placedAt: nowUnix,
|
|
||||||
lastVisitedAt: nowUnix,
|
|
||||||
lastFedAt: nowUnix,
|
|
||||||
cellsWide: w,
|
|
||||||
cellsHigh: h,
|
|
||||||
fromOtherZoo: baby.fromOtherZoo === true,
|
|
||||||
};
|
|
||||||
fillAnimalBlock(state, toX, toY, animalData);
|
|
||||||
state.pendingBabies = (state.pendingBabies ?? []).filter((p) => p.nurseryCellKey !== nurseryCellKey);
|
state.pendingBabies = (state.pendingBabies ?? []).filter((p) => p.nurseryCellKey !== nurseryCellKey);
|
||||||
state.lastEvolutionAt = nowUnix;
|
state.lastEvolutionAt = nowUnix;
|
||||||
return [true, undefined];
|
return [true, undefined];
|
||||||
@@ -160,28 +142,9 @@ export function placeMatureBabyOnCell(state, opts) {
|
|||||||
*/
|
*/
|
||||||
export function placeReceptionAnimalOnCell(state, opts) {
|
export function placeReceptionAnimalOnCell(state, opts) {
|
||||||
const { receptionCellKey, toX, toY, nowUnix } = opts;
|
const { receptionCellKey, toX, toY, nowUnix } = opts;
|
||||||
const rec = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === receptionCellKey);
|
const [ok, result] = getReceptionAnimalPlacementData(state, opts);
|
||||||
if (rec === null || rec === undefined) return [false, "NoReceptionAnimal"];
|
if (!ok) return [false, result];
|
||||||
if (nowUnix < rec.readyAt) return [false, "AnimalNotReady"];
|
fillAnimalBlock(state, toX, toY, result);
|
||||||
const def = LootTables.Animals[rec.animalId];
|
|
||||||
if (def === null || def === undefined) return [false, "UnknownAnimal"];
|
|
||||||
const w = def.cellsWide ?? 1;
|
|
||||||
const h = def.cellsHigh ?? 1;
|
|
||||||
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
|
|
||||||
if (!ok) return [false, reason];
|
|
||||||
const animalData = {
|
|
||||||
kind: "animal",
|
|
||||||
id: rec.animalId,
|
|
||||||
mutation: "none",
|
|
||||||
level: 1,
|
|
||||||
placedAt: nowUnix,
|
|
||||||
lastVisitedAt: nowUnix,
|
|
||||||
lastFedAt: nowUnix,
|
|
||||||
cellsWide: w,
|
|
||||||
cellsHigh: h,
|
|
||||||
fromOtherZoo: true,
|
|
||||||
};
|
|
||||||
fillAnimalBlock(state, toX, toY, animalData);
|
|
||||||
state.receptionAnimals = (state.receptionAnimals ?? []).filter((r) => r.receptionCellKey !== receptionCellKey);
|
state.receptionAnimals = (state.receptionAnimals ?? []).filter((r) => r.receptionCellKey !== receptionCellKey);
|
||||||
state.lastEvolutionAt = nowUnix;
|
state.lastEvolutionAt = nowUnix;
|
||||||
return [true, undefined];
|
return [true, undefined];
|
||||||
|
|||||||
Reference in New Issue
Block a user