Files
builazoo/web/js/income.js
Nicolas Cantu e031c9a1d2 Initial commit
**Motivations:**
- Initialisation du versionning git pour le projet

**Root causes:**
- N/A (Nouveau projet)

**Correctifs:**
- N/A

**Evolutions:**
- Structure initiale du projet
- Ajout du .gitignore

**Pages affectées:**
- Tous les fichiers
2026-03-03 22:24:17 +01:00

289 lines
11 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, getSellValue } from "./economy.js";
import { GameConfig } from "./config.js";
import { getPrestigeIncomeMultiplier } from "./prestige.js";
import { isOriginCell } from "./grid-utils.js";
import { getOriginAnimalCount } from "./food.js";
/**
* Total sell value of all animals in the zoo (used for visitor attraction). Counts each animal block once (origin cell only).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getTotalAnimalValue(state) {
let total = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip non-origin animals
} else {
const animalDef = LootTables.Animals[cell.id];
if (animalDef !== null && animalDef !== undefined) {
const mutationMult = getIncomeMultiplier(cell.mutation);
total += getSellValue(
animalDef.baseIncomePerSecond,
cell.level,
mutationMult,
animalDef.sellFactor
);
}
}
}
return total;
}
/**
* Max simultaneous visitors allowed by billeterie capacity. Entry is only via billeterie.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getBilleterieCapacity(state) {
const cfg = GameConfig.Billeterie;
if (!cfg) return 0;
const unit = cfg.VisitorsPerUnit ?? 20;
let total = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "billeterie") {
total += (cell.level ?? 1) * unit;
}
}
return total;
}
/**
* Attraction from cities: per-city contribution = min(maxVisitorsTowardZoos, rawWeight * 100), summed then scaled.
* Closer cities contribute more, but each city is capped by maxVisitorsTowardZoos.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getCityAttraction(state) {
const cities = GameConfig.WorldMap?.Cities;
if (!cities || cities.length === 0) return 0;
const zoos = state.worldZoos ?? [];
const player = zoos.find((z) => z.id === "player");
if (!player) return 0;
const scale = GameConfig.Visitor.CityAttractionScale ?? 0.002;
const rawMultiplier = 100;
let sum = 0;
for (const city of cities) {
const dx = (city.x - player.x) / 100;
const dy = (city.y - player.y) / 100;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
const raw = 1 / (1 + dist);
const maxFromCity = city.maxVisitorsTowardZoos ?? 999;
const contrib = Math.min(maxFromCity, raw * rawMultiplier);
sum += contrib;
}
return sum * scale;
}
/**
* Decay multiplier when the zoo has not evolved (upgrade/place/sell) for a while.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {number}
*/
function getStagnationMultiplier(state, nowUnix) {
const after = GameConfig.Visitor.StagnationDecayAfterSeconds ?? 60;
const perMin = GameConfig.Visitor.StagnationDecayPerMinute ?? 0.05;
const last = state.lastEvolutionAt ?? 0;
const elapsed = Math.max(0, nowUnix - last);
if (elapsed <= after) return 1;
const minutesStagnant = (elapsed - after) / 60;
const decay = Math.min(0.9, minutesStagnant * perMin);
return Math.max(0.1, 1 - decay);
}
/**
* Stay duration multiplier from boutiques and animal diversity (visitors stay longer).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayMultiplier(state) {
let shopBonus = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") {
shopBonus += (cell.level ?? 1) * (GameConfig.Visitor.StayMultiplierPerShopLevel ?? 0.15);
}
}
const speciesSet = new Set();
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) speciesSet.add(cell.id);
}
const diversityBonus = speciesSet.size * (GameConfig.Visitor.StayMultiplierPerSpecies ?? 0.02);
return Math.max(0.5, 1 + shopBonus + diversityBonus);
}
/**
* Stay duration in seconds (base 1 day × stay multiplier). Visitors leave when now > arrivedAt + this.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayDurationSeconds(state) {
const base = GameConfig.Time?.DayLengthSeconds ?? 120;
return base * getStayMultiplier(state);
}
/**
* Demand for visitors (before billeterie cap).
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {number}
*/
function getVisitorDemand(state, nowUnix) {
let animalCount = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1;
}
const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal;
const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus;
let demand = Math.floor(animalCount * visitorsPerAnimal + plotBonus);
const cityAttraction = getCityAttraction(state);
const animalValue = getTotalAnimalValue(state);
const animalValueScale = GameConfig.Visitor.AnimalValueScale ?? 0.00015;
demand *= 1 + cityAttraction;
demand *= 1 + animalValue * animalValueScale;
demand *= getStagnationMultiplier(state, nowUnix);
return Math.max(0, Math.floor(demand));
}
/**
* Update visitor entities: remove those who exceeded stay duration, add new arrivals up to min(cap, demand).
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function tickVisitorArrivals(state, nowUnix) {
state.visitorArrivals = state.visitorArrivals ?? [];
const stayDuration = getStayDurationSeconds(state);
state.visitorArrivals = state.visitorArrivals.filter(
(v) => nowUnix < v.arrivedAt + stayDuration
);
const demand = getVisitorDemand(state, nowUnix);
const cap = getBilleterieCapacity(state);
const target = Math.min(cap, demand);
const current = state.visitorArrivals.length;
for (let i = 0; i < target - current; i++) {
state.visitorArrivals.push({ arrivedAt: nowUnix });
}
}
/**
* Visitor count and average payment per visitor per second. Includes luxury guest effect (LuxuryGuestChance, LuxuryEntryMultiplier, LuxuryShopMultiplier) in the average.
* @param {import("./types.js").GameState} state
* @returns {{ visitorCount: number, paymentPerVisitor: number }}
*/
function getVisitorParams(state) {
const arrivals = state.visitorArrivals ?? [];
let visitorCount = arrivals.length;
if (visitorCount === 0 && getBilleterieCapacity(state) === 0) {
let animalCount = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1;
}
const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal;
const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus;
visitorCount = Math.max(0, Math.floor(animalCount * visitorsPerAnimal + plotBonus));
}
const billeterieCap = getBilleterieCapacity(state);
if (billeterieCap > 0 && visitorCount > billeterieCap) visitorCount = billeterieCap;
let paymentPerVisitor = GameConfig.Visitor.BasePaymentPerVisitor;
let souvenirBonus = 1;
let shopCount = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell && cell.kind === "souvenirShop") shopCount += (cell.level ?? 1);
}
if (shopCount > 0) {
const bonusPerShop = GameConfig.Visitor.SouvenirShopBonusPerShop ?? 0.2;
souvenirBonus = 1 + shopCount * bonusPerShop;
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryShopMult = GameConfig.Visitor.LuxuryShopMultiplier ?? 1;
if (luxuryChance > 0 && luxuryShopMult > 1) {
souvenirBonus *= 1 + luxuryChance * (luxuryShopMult - 1);
}
}
paymentPerVisitor *= souvenirBonus;
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryEntryMult = GameConfig.Visitor.LuxuryEntryMultiplier ?? 1;
if (luxuryChance > 0 && luxuryEntryMult > 1) {
paymentPerVisitor *= 1 + luxuryChance * (luxuryEntryMult - 1);
}
return { visitorCount, paymentPerVisitor };
}
export function getVisitorCount(state) {
return getVisitorParams(state).visitorCount;
}
/**
* Attractivity score for display and future city allocation. Formula: value + species + rarity + fill rate, minus death penalty, plus birth bonus.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getAttractivityScore(state) {
const value = getTotalAnimalValue(state);
const originCount = getOriginAnimalCount(state);
const grid = state.grid;
const cellCount = grid.width * grid.height;
const fillRate = cellCount > 0 ? originCount / cellCount : 0;
const speciesSet = new Set();
let raritySum = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip
} else {
speciesSet.add(cell.id);
const def = LootTables.Animals[cell.id];
if (def) raritySum += def.rarityLevel ?? 1;
}
}
const speciesCount = speciesSet.size;
const avgRarity = originCount > 0 ? raritySum / originCount : 0;
const valueNorm = value * 0.001;
const speciesNorm = speciesCount * 2;
const rarityNorm = avgRarity * 0.5;
const fillNorm = fillRate * 10;
let score = valueNorm + speciesNorm + rarityNorm + fillNorm;
const deathPenalty = GameConfig.Visitor?.AttractivityDeathPenalty ?? 0.5;
const birthBonus = GameConfig.Visitor?.AttractivityBirthBonus ?? 0.2;
const deaths = state.deathCountRecent ?? 0;
const births = state.birthCount ?? 0;
score -= deathPenalty * deaths;
score += birthBonus * births;
const incidentBonus = state.attractivityBonusFromIncidents ?? 0;
score += incidentBonus;
return Math.max(0, score);
}
/**
* @param {import("./types.js").AnimalCell} cell
* @returns {number}
*/
function incomePerSecond(cell) {
const animalDef = LootTables.Animals[cell.id];
if (animalDef === null || animalDef === undefined) throw new Error("IncomeService: unknown animal");
const mutationMult = getIncomeMultiplier(cell.mutation);
const levelMult = getLevelMultiplier(cell.level);
return animalDef.baseIncomePerSecond * mutationMult * levelMult;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} dt
* @param {{ incomeMultiplier: number }} eventModifiers
* @returns {{ animal: number, visitor: number }}
*/
export function tick(state, dt, eventModifiers) {
const prestigeMult = getPrestigeIncomeMultiplier(state.prestigeLevel);
let animalTotal = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell))
animalTotal += incomePerSecond(cell) * dt * eventModifiers.incomeMultiplier * prestigeMult;
}
const { visitorCount, paymentPerVisitor } = getVisitorParams(state);
const visitorTotal = visitorCount * paymentPerVisitor * dt * prestigeMult;
const total = animalTotal + visitorTotal;
state.coins += total;
if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + total;
return { animal: animalTotal, visitor: visitorTotal };
}