Files
builazoo/web/js/income.js
ncantu c7d389ecbb 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
2026-03-04 15:32:27 +01:00

363 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LootTables } from "./loot-tables.js";
import { getIncomeMultiplier } from "./mutation-rules.js";
import { getLevelMultiplier } from "./economy.js";
import { GameConfig } from "./config.js";
import { getPrestigeIncomeMultiplier } from "./prestige.js";
import { isOriginCell } from "./grid-utils.js";
import { getCurrentSeason, getSeasonVisitorMultiplier, getSeasonTicketPriceMultiplier } from "./seasons.js";
import { getTotalAnimalValue } from "./income-value.js";
import { getAttractivityBase } from "./income-attractivity.js";
/**
* Visitor demand multiplier by time of day (spec visiteur: 08h-10h faible, 10h-16h fort, 16h-18h décroissant, >18h nul).
* @param {number} timeOfDay 0..24
* @returns {number}
*/
function getVisitorDemandHourMultiplier(timeOfDay) {
const t = timeOfDay % 24;
if (t < 8 || t >= 20) return 0;
if (t >= 8 && t < 10) return 0.5;
if (t >= 10 && t < 16) return 1;
if (t >= 16 && t < 18) return 0.7;
return 0.3;
}
/**
* Max simultaneous visitors allowed by billeterie capacity. Entry is only via billeterie.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getBilleterieCapacity(state) {
const cfg = GameConfig.Billeterie;
if (!cfg) return 0;
const unit = cfg.VisitorsPerUnit ?? 20;
let total = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "billeterie") {
total += (cell.level ?? 1) * unit;
}
}
return total;
}
/**
* Attraction from cities: per-city contribution = min(maxVisitorsTowardZoos, rawWeight * 100), summed then scaled.
* Closer cities contribute more, but each city is capped by maxVisitorsTowardZoos.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getCityAttraction(state) {
const cities = GameConfig.WorldMap?.Cities;
if (!cities || cities.length === 0) return 0;
const zoos = state.worldZoos ?? [];
const player = zoos.find((z) => z.id === "player");
if (!player) return 0;
const scale = GameConfig.Visitor.CityAttractionScale ?? 0.002;
const rawMultiplier = 100;
let sum = 0;
for (const city of cities) {
const dx = (city.x - player.x) / 100;
const dy = (city.y - player.y) / 100;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
const raw = 1 / (1 + dist);
const maxFromCity = city.maxVisitorsTowardZoos ?? 999;
const contrib = Math.min(maxFromCity, raw * rawMultiplier);
sum += contrib;
}
return sum * scale;
}
/**
* Decay multiplier when the zoo has not evolved (upgrade/place/sell) for a while.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {number}
*/
function getStagnationMultiplier(state, nowUnix) {
const after = GameConfig.Visitor.StagnationDecayAfterSeconds ?? 60;
const perMin = GameConfig.Visitor.StagnationDecayPerMinute ?? 0.05;
const last = state.lastEvolutionAt ?? 0;
const elapsed = Math.max(0, nowUnix - last);
if (elapsed <= after) return 1;
const minutesStagnant = (elapsed - after) / 60;
const decay = Math.min(0.9, minutesStagnant * perMin);
return Math.max(0.1, 1 - decay);
}
/**
* Shop bonus component of stay multiplier (souvenir shops).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayMultiplierShopBonus(state) {
let shopBonus = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") {
shopBonus += (cell.level ?? 1) * (GameConfig.Visitor.StayMultiplierPerShopLevel ?? 0.15);
}
}
return shopBonus;
}
/**
* Diversity bonus component (species count).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayMultiplierDiversityBonus(state) {
const speciesSet = new Set();
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) speciesSet.add(cell.id);
}
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);
}
/**
* Stay duration in seconds (base 1 day × stay multiplier). Visitors leave when now > arrivedAt + this.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayDurationSeconds(state) {
const base = GameConfig.Time?.DayLengthSeconds ?? 120;
return base * getStayMultiplier(state);
}
/**
* Demand for visitors (before billeterie cap).
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {number}
*/
function getVisitorDemand(state, nowUnix) {
let animalCount = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1;
}
const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal;
const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus;
let demand = Math.floor(animalCount * visitorsPerAnimal + plotBonus);
const cityAttraction = getCityAttraction(state);
const animalValue = getTotalAnimalValue(state);
const animalValueScale = GameConfig.Visitor.AnimalValueScale ?? 0.00015;
demand *= 1 + cityAttraction;
demand *= 1 + animalValue * animalValueScale;
demand *= getStagnationMultiplier(state, nowUnix);
const seasonMult = getSeasonVisitorMultiplier(getCurrentSeason(state));
demand *= seasonMult;
const hourMult = getVisitorDemandHourMultiplier(state.timeOfDay ?? 6);
demand *= hourMult;
return Math.max(0, Math.floor(demand));
}
/**
* Remove visitors who exceeded stay duration.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
function filterExpiredVisitors(state, nowUnix) {
const stayDuration = getStayDurationSeconds(state);
state.visitorArrivals = (state.visitorArrivals ?? []).filter(
(v) => nowUnix < v.arrivedAt + stayDuration
);
}
/**
* Whether we are within opening hours for new entries.
* @param {import("./types.js").GameState} state
* @returns {boolean}
*/
function isVisitorOpeningHours(state) {
const timeOfDay = state.timeOfDay ?? 6;
const openHour = GameConfig.Billeterie?.OpenHour ?? 8;
const closeHour = GameConfig.Billeterie?.CloseHour ?? 20;
return timeOfDay >= openHour && timeOfDay < closeHour;
}
/**
* Update visitor entities: remove those who exceeded stay duration, add new arrivals up to min(cap, demand).
* New arrivals only during opening hours (OpenHourCloseHour). Max MaxEntryPerSecond new visitors per second.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function tickVisitorArrivals(state, nowUnix) {
state.visitorArrivals = state.visitorArrivals ?? [];
filterExpiredVisitors(state, nowUnix);
if (!isVisitorOpeningHours(state)) return;
const demand = getVisitorDemand(state, nowUnix);
const cap = getBilleterieCapacity(state);
const target = Math.min(cap, demand);
const current = state.visitorArrivals.length;
const maxToAdd = target - current;
if (maxToAdd <= 0) return;
const maxPerSecond = GameConfig.Billeterie?.MaxEntryPerSecond ?? 1;
const secondsPerTick = GameConfig.IncomeTickMs / 1000;
const maxThisTick = Math.min(maxToAdd, Math.ceil(maxPerSecond * secondsPerTick));
for (let i = 0; i < maxThisTick; i++) {
state.visitorArrivals.push({ arrivedAt: nowUnix });
}
}
/**
* Raw visitor count from animals and plot when no billeterie (for fallback).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getVisitorCountFallback(state) {
let animalCount = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1;
}
const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal;
const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus;
return Math.max(0, Math.floor(animalCount * visitorsPerAnimal + plotBonus));
}
/**
* Visitor count capped by billeterie.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getVisitorCountCapped(state) {
const arrivals = state.visitorArrivals ?? [];
let visitorCount = arrivals.length;
if (visitorCount === 0 && getBilleterieCapacity(state) === 0) {
visitorCount = getVisitorCountFallback(state);
}
const billeterieCap = getBilleterieCapacity(state);
if (billeterieCap > 0 && visitorCount > billeterieCap) visitorCount = billeterieCap;
return visitorCount;
}
/**
* Luxury shop multiplier component for souvenir bonus (>= 1).
* @returns {number}
*/
function getLuxuryShopMultiplier() {
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryShopMult = GameConfig.Visitor.LuxuryShopMultiplier ?? 1;
if (luxuryChance > 0 && luxuryShopMult > 1) {
return 1 + luxuryChance * (luxuryShopMult - 1);
}
return 1;
}
/**
* Souvenir shop bonus multiplier (>= 1).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getSouvenirBonus(state) {
let shopCount = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell && cell.kind === "souvenirShop") shopCount += (cell.level ?? 1);
}
if (shopCount === 0) return 1;
const bonusPerShop = GameConfig.Visitor.SouvenirShopBonusPerShop ?? 0.2;
return (1 + shopCount * bonusPerShop) * getLuxuryShopMultiplier();
}
/**
* Luxury entry multiplier (>= 1).
* @returns {number}
*/
function getLuxuryEntryMultiplier() {
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryEntryMult = GameConfig.Visitor.LuxuryEntryMultiplier ?? 1;
if (luxuryChance > 0 && luxuryEntryMult > 1) {
return 1 + luxuryChance * (luxuryEntryMult - 1);
}
return 1;
}
/**
* Payment per visitor (base × souvenir × luxury × season).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getPaymentPerVisitor(state) {
let paymentPerVisitor = GameConfig.Visitor.BasePaymentPerVisitor;
paymentPerVisitor *= getSouvenirBonus(state);
paymentPerVisitor *= getLuxuryEntryMultiplier();
const ticketSeasonMult = getSeasonTicketPriceMultiplier(getCurrentSeason(state));
paymentPerVisitor *= ticketSeasonMult;
return paymentPerVisitor;
}
/**
* Visitor count and average payment per visitor per second. Includes luxury guest effect (LuxuryGuestChance, LuxuryEntryMultiplier, LuxuryShopMultiplier) in the average.
* @param {import("./types.js").GameState} state
* @returns {{ visitorCount: number, paymentPerVisitor: number }}
*/
function getVisitorParams(state) {
const visitorCount = getVisitorCountCapped(state);
const paymentPerVisitor = getPaymentPerVisitor(state);
return { visitorCount, paymentPerVisitor };
}
export function getVisitorCount(state) {
return getVisitorParams(state).visitorCount;
}
/**
* Attractivity score for display and future city allocation. Formula: value + species + rarity + fill rate, minus death penalty, plus birth bonus.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getAttractivityScore(state) {
const { valueNorm, speciesNorm, rarityNorm, fillNorm } = getAttractivityBase(state);
let score = valueNorm + speciesNorm + rarityNorm + fillNorm;
const deathPenalty = GameConfig.Visitor?.AttractivityDeathPenalty ?? 0.5;
const birthBonus = GameConfig.Visitor?.AttractivityBirthBonus ?? 0.2;
const deaths = state.deathCountRecent ?? 0;
const births = state.birthCount ?? 0;
score -= deathPenalty * deaths;
score += birthBonus * births;
const incidentBonus = state.attractivityBonusFromIncidents ?? 0;
score += incidentBonus;
return Math.max(0, score);
}
/**
* @param {import("./types.js").AnimalCell} cell
* @returns {number}
*/
function incomePerSecond(cell) {
const animalDef = LootTables.Animals[cell.id];
if (animalDef === null || animalDef === undefined) throw new Error("IncomeService: unknown animal");
const mutationMult = getIncomeMultiplier(cell.mutation);
const levelMult = getLevelMultiplier(cell.level);
return animalDef.baseIncomePerSecond * mutationMult * levelMult;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} dt
* @param {{ incomeMultiplier: number }} eventModifiers
* @returns {{ animal: number, visitor: number }}
*/
export function tick(state, dt, eventModifiers) {
const prestigeMult = getPrestigeIncomeMultiplier(state.prestigeLevel);
let animalTotal = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell))
animalTotal += incomePerSecond(cell) * dt * eventModifiers.incomeMultiplier * prestigeMult;
}
const { visitorCount, paymentPerVisitor } = getVisitorParams(state);
const visitorTotal = visitorCount * paymentPerVisitor * dt * prestigeMult;
const total = animalTotal + visitorTotal;
state.coins += total;
if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + total;
return { animal: animalTotal, visitor: visitorTotal };
}