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 (OpenHour–CloseHour). Max MaxEntryPerSecond new visitors per second. * @param {import("./types.js").GameState} state * @param {number} nowUnix */ export function tickVisitorArrivals(state, nowUnix) { state.visitorArrivals = state.visitorArrivals ?? []; 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 }; }