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
This commit is contained in:
359
web/js/zoo.js
Normal file
359
web/js/zoo.js
Normal file
@@ -0,0 +1,359 @@
|
||||
import { GameConfig } from "./config.js";
|
||||
import { LootTables, getRarityHatchMultiplierForEggType } from "./loot-tables.js";
|
||||
import { plotSizeFromLevel } from "./grid-utils.js";
|
||||
import { getPlotUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js";
|
||||
import { findOffer } from "./conveyor.js";
|
||||
import { placeEgg, fillAnimalBlock, canPlaceMultiCell } from "./placement.js";
|
||||
import { getNurseryCellKeysOrdered, getFreeNurseryCellKey } from "./zoo-nursery.js";
|
||||
|
||||
export { getNurseryCellKeysOrdered, getFreeNurseryCellKey };
|
||||
/**
|
||||
* First reception cell key that has no reception animal. Returns null if none.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function getFreeReceptionCellKey(state) {
|
||||
const usedKeys = new Set((state.receptionAnimals ?? []).map((r) => r.receptionCellKey));
|
||||
for (const [key, cell] of Object.entries(state.grid.cells)) {
|
||||
if (cell && cell.kind === "reception" && !usedKeys.has(key)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Growth duration in seconds for a baby in a nursery of the given level.
|
||||
* @param {number} nurseryLevel
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getBabyGrowthSeconds(nurseryLevel) {
|
||||
const base = GameConfig.Nursery?.GrowthSecondsBase ?? 40;
|
||||
const level = Math.max(1, nurseryLevel ?? 1);
|
||||
return Math.max(5, Math.floor(base / level));
|
||||
}
|
||||
|
||||
/**
|
||||
* Acclimatation duration in seconds for reception of the given level.
|
||||
* @param {number} receptionLevel
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getAcclimatationSeconds(receptionLevel) {
|
||||
const base = GameConfig.Reception?.AcclimatationSecondsBase ?? 45;
|
||||
const level = Math.max(1, receptionLevel ?? 1);
|
||||
return Math.max(10, Math.floor(base / level));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a baby to the first free nursery slot. Mutates state.pendingBabies and state.nextTokenId.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {string} animalId
|
||||
* @param {boolean} [fromOtherZoo] true if baby was bought from another zoo (conveyor/world), false if bred
|
||||
* @returns {[boolean, string?]} [ok, nurseryCellKey or reason]
|
||||
*/
|
||||
export function addPendingBaby(state, animalId, fromOtherZoo) {
|
||||
const key = getFreeNurseryCellKey(state);
|
||||
if (key === null || key === undefined) return [false, "NoFreeNursery"];
|
||||
if (LootTables.Animals[animalId] === null || LootTables.Animals[animalId] === undefined) return [false, "UnknownAnimal"];
|
||||
const cell = state.grid.cells[key];
|
||||
const level = cell && cell.kind === "nursery" ? (cell.level ?? 1) : 1;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const readyAt = now + getBabyGrowthSeconds(level);
|
||||
const id = `baby_${state.nextTokenId}`;
|
||||
state.nextTokenId += 1;
|
||||
state.pendingBabies = state.pendingBabies ?? [];
|
||||
state.pendingBabies.push({ id, animalId, nurseryCellKey: key, readyAt, fromOtherZoo: fromOtherZoo === true });
|
||||
state.lastEvolutionAt = now;
|
||||
return [true, key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an animal to the first free reception slot.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {string} animalId
|
||||
* @returns {[boolean, string?]} [ok, receptionCellKey or reason]
|
||||
*/
|
||||
export function addReceptionAnimal(state, animalId) {
|
||||
const key = getFreeReceptionCellKey(state);
|
||||
if (key === null || key === undefined) return [false, "NoFreeReception"];
|
||||
if (LootTables.Animals[animalId] === null || LootTables.Animals[animalId] === undefined) return [false, "UnknownAnimal"];
|
||||
const cell = state.grid.cells[key];
|
||||
const level = cell && cell.kind === "reception" ? (cell.level ?? 1) : 1;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const readyAt = now + getAcclimatationSeconds(level);
|
||||
const id = `reception_${state.nextTokenId}`;
|
||||
state.nextTokenId += 1;
|
||||
state.receptionAnimals = state.receptionAnimals ?? [];
|
||||
state.receptionAnimals.push({ id, animalId, receptionCellKey: key, readyAt });
|
||||
state.lastEvolutionAt = now;
|
||||
return [true, key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy a baby offer: pay price and add to nursery if slot free.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {string} animalId
|
||||
* @param {number} price
|
||||
* @returns {[boolean, string | { nurseryCellKey: string }]}
|
||||
*/
|
||||
export function tryBuyBaby(state, animalId, price) {
|
||||
if (state.coins < price) return [false, "NotEnoughCoins"];
|
||||
const [ok, result] = addPendingBaby(state, animalId, true);
|
||||
if (!ok) return [false, result];
|
||||
state.coins -= price;
|
||||
return [true, { nurseryCellKey: result }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy an animal offer: pay price and add to reception if slot free.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {string} animalId
|
||||
* @param {number} price
|
||||
* @returns {[boolean, string | { receptionCellKey: string }]}
|
||||
*/
|
||||
export function tryBuyAnimal(state, animalId, price) {
|
||||
if (state.coins < price) return [false, "NotEnoughCoins"];
|
||||
const [ok, result] = addReceptionAnimal(state, animalId);
|
||||
if (!ok) return [false, result];
|
||||
state.coins -= price;
|
||||
return [true, { receptionCellKey: result }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a mature baby on an empty cell. Baby must be at nurseryCellKey and readyAt <= now.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {{ nurseryCellKey: string, toX: number, toY: number, nowUnix: number }} opts
|
||||
* @returns {[boolean, string?]}
|
||||
*/
|
||||
export function placeMatureBabyOnCell(state, opts) {
|
||||
const { nurseryCellKey, toX, toY, nowUnix } = opts;
|
||||
const baby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === nurseryCellKey);
|
||||
if (baby === null || baby === undefined) return [false, "NoBaby"];
|
||||
if (nowUnix < baby.readyAt) return [false, "BabyNotReady"];
|
||||
const def = LootTables.Animals[baby.animalId];
|
||||
if (def === null || def === undefined) return [false, "UnknownAnimal"];
|
||||
const w = def.cellsWide ?? 1;
|
||||
const h = def.cellsHigh ?? 1;
|
||||
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
|
||||
if (!ok) return [false, reason];
|
||||
const animalData = {
|
||||
kind: "animal",
|
||||
id: baby.animalId,
|
||||
mutation: "none",
|
||||
level: 1,
|
||||
placedAt: nowUnix,
|
||||
lastVisitedAt: nowUnix,
|
||||
lastFedAt: nowUnix,
|
||||
cellsWide: w,
|
||||
cellsHigh: h,
|
||||
fromOtherZoo: baby.fromOtherZoo === true,
|
||||
};
|
||||
fillAnimalBlock(state, toX, toY, animalData);
|
||||
state.pendingBabies = (state.pendingBabies ?? []).filter((p) => p.nurseryCellKey !== nurseryCellKey);
|
||||
state.lastEvolutionAt = nowUnix;
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a ready reception animal on an empty cell.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {{ receptionCellKey: string, toX: number, toY: number, nowUnix: number }} opts
|
||||
* @returns {[boolean, string?]}
|
||||
*/
|
||||
export function placeReceptionAnimalOnCell(state, opts) {
|
||||
const { receptionCellKey, toX, toY, nowUnix } = opts;
|
||||
const rec = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === receptionCellKey);
|
||||
if (rec === null || rec === undefined) return [false, "NoReceptionAnimal"];
|
||||
if (nowUnix < rec.readyAt) return [false, "AnimalNotReady"];
|
||||
const def = LootTables.Animals[rec.animalId];
|
||||
if (def === null || def === undefined) return [false, "UnknownAnimal"];
|
||||
const w = def.cellsWide ?? 1;
|
||||
const h = def.cellsHigh ?? 1;
|
||||
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
|
||||
if (!ok) return [false, reason];
|
||||
const animalData = {
|
||||
kind: "animal",
|
||||
id: rec.animalId,
|
||||
mutation: "none",
|
||||
level: 1,
|
||||
placedAt: nowUnix,
|
||||
lastVisitedAt: nowUnix,
|
||||
lastFedAt: nowUnix,
|
||||
cellsWide: w,
|
||||
cellsHigh: h,
|
||||
fromOtherZoo: true,
|
||||
};
|
||||
fillAnimalBlock(state, toX, toY, animalData);
|
||||
state.receptionAnimals = (state.receptionAnimals ?? []).filter((r) => r.receptionCellKey !== receptionCellKey);
|
||||
state.lastEvolutionAt = nowUnix;
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a token to the first nursery cell that has no tokenId. Mutates state.grid.cells.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {number} tokenId
|
||||
* @returns {boolean} true if assigned
|
||||
*/
|
||||
export function assignTokenToNursery(state, tokenId) {
|
||||
const keys = getNurseryCellKeysOrdered(state);
|
||||
for (const key of keys) {
|
||||
const cell = state.grid.cells[key];
|
||||
if (cell && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined)) {
|
||||
cell.tokenId = tokenId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the nursery cell that holds this tokenId.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {number} tokenId
|
||||
*/
|
||||
export function clearNurseryToken(state, tokenId) {
|
||||
for (const cell of Object.values(state.grid.cells)) {
|
||||
if (cell && cell.kind === "nursery" && cell.tokenId === tokenId) {
|
||||
cell.tokenId = undefined;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nursery level of the cell that holds this token (for hatch duration). Returns 1 if not found.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {number} tokenId
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getNurseryLevelForToken(state, tokenId) {
|
||||
for (const cell of Object.values(state.grid.cells)) {
|
||||
if (cell && cell.kind === "nursery" && cell.tokenId === tokenId) {
|
||||
return cell.level ?? 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hatch duration in seconds when placing from a nursery (rarity slows down, nursery level speeds up).
|
||||
* @param {string} eggType
|
||||
* @param {number} nurseryLevel
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getHatchDurationSeconds(eggType, nurseryLevel) {
|
||||
const eggDef = LootTables.EggTypes[eggType];
|
||||
if (eggDef === null || eggDef === undefined) return 30;
|
||||
const base = eggDef.hatchSeconds;
|
||||
const rarityMult = getRarityHatchMultiplierForEggType(eggType);
|
||||
const level = Math.max(1, nurseryLevel ?? 1);
|
||||
return Math.max(5, Math.floor((base * rarityMult) / level));
|
||||
}
|
||||
|
||||
function consumeToken(state, tokenId) {
|
||||
const idx = state.pendingEggTokens.findIndex((t) => t.tokenId === tokenId);
|
||||
if (idx < 0) return null;
|
||||
const [token] = state.pendingEggTokens.splice(idx, 1);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {string} eggType
|
||||
* @returns {[boolean, { tokenId: number, eggType: string } | string]}
|
||||
*/
|
||||
export function tryBuyEgg(state, eggType) {
|
||||
const offer = findOffer(state, eggType);
|
||||
if (offer === null || offer === undefined) return [false, "OfferUnavailable"];
|
||||
const auctionBonus = Math.floor(Math.random() * (offer.price * 0.21));
|
||||
const finalPrice = offer.price + auctionBonus;
|
||||
if (state.coins < finalPrice) return [false, "NotEnoughCoins"];
|
||||
if (LootTables.EggTypes[eggType] === null || LootTables.EggTypes[eggType] === undefined) return [false, "UnknownEgg"];
|
||||
state.coins -= finalPrice;
|
||||
const token = { tokenId: state.nextTokenId, eggType, boughtAt: Math.floor(Date.now() / 1000) };
|
||||
state.nextTokenId += 1;
|
||||
state.pendingEggTokens.push(token);
|
||||
assignTokenToNursery(state, token.tokenId);
|
||||
return [true, { tokenId: token.tokenId, eggType: token.eggType }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy egg from laboratory offer (fixed price, no auction).
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {string} eggType
|
||||
* @returns {[boolean, { tokenId: number, eggType: string } | string]}
|
||||
*/
|
||||
export function tryBuyLabEgg(state, eggType) {
|
||||
const offer = state.laboratoryOffer;
|
||||
if (offer === null || offer === undefined || offer.eggType !== eggType) return [false, "OfferUnavailable"];
|
||||
if (state.coins < offer.price) return [false, "NotEnoughCoins"];
|
||||
if (LootTables.EggTypes[eggType] === null || LootTables.EggTypes[eggType] === undefined) return [false, "UnknownEgg"];
|
||||
state.coins -= offer.price;
|
||||
state.laboratoryOffer = null;
|
||||
const token = { tokenId: state.nextTokenId, eggType, boughtAt: Math.floor(Date.now() / 1000) };
|
||||
state.nextTokenId += 1;
|
||||
state.pendingEggTokens.push(token);
|
||||
assignTokenToNursery(state, token.tokenId);
|
||||
return [true, { tokenId: token.tokenId, eggType: token.eggType }];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {{ tokenId: number, x: number, y: number, nowUnix: number }} opts
|
||||
* @returns {[boolean, string?]}
|
||||
*/
|
||||
export function tryPlaceEgg(state, opts) {
|
||||
const { tokenId, x, y, nowUnix } = opts;
|
||||
const token = consumeToken(state, tokenId);
|
||||
if (token === null || token === undefined) return [false, "InvalidToken"];
|
||||
const eggDef = LootTables.EggTypes[token.eggType];
|
||||
if (eggDef === null || eggDef === undefined) throw new Error("ZooService: token contains unknown egg type");
|
||||
const nurseryLevel = getNurseryLevelForToken(state, tokenId);
|
||||
const hatchSeconds = getHatchDurationSeconds(token.eggType, nurseryLevel);
|
||||
const hatchAt = nowUnix + hatchSeconds;
|
||||
const seed = Math.floor(Math.random() * 2000000000) + 1;
|
||||
const [ok, reason] = placeEgg(state, { eggType: token.eggType, tokenId, x, y, hatchAt, seed });
|
||||
if (!ok) {
|
||||
state.pendingEggTokens.push(token);
|
||||
return [false, reason];
|
||||
}
|
||||
clearNurseryToken(state, tokenId);
|
||||
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
|
||||
if (state.stats) state.stats.eggsPlaced = (state.stats.eggsPlaced ?? 0) + 1;
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @returns {[boolean, string?]}
|
||||
*/
|
||||
export function tryUpgradePlot(state) {
|
||||
if (state.plotLevel >= GameConfig.Plot.MaxLevel) return [false, "PlotMaxLevel"];
|
||||
const cost = getPlotUpgradeCost(state.plotLevel);
|
||||
if (state.coins < cost) return [false, "NotEnoughCoins"];
|
||||
state.coins -= cost;
|
||||
state.plotLevel += 1;
|
||||
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
|
||||
const [width, height] = plotSizeFromLevel(state.plotLevel);
|
||||
state.grid.width = width;
|
||||
state.grid.height = height;
|
||||
if (state.stats) state.stats.plotUpgrades = (state.stats.plotUpgrades ?? 0) + 1;
|
||||
return [true, undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @returns {[boolean, string?]}
|
||||
*/
|
||||
export function tryUpgradeWorldMap(state) {
|
||||
const cfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade;
|
||||
const maxLevel = cfg ? cfg.MaxLevel : 5;
|
||||
const level = state.worldMapLevel ?? 1;
|
||||
if (level >= maxLevel) return [false, "WorldMapMaxLevel"];
|
||||
const cost = getWorldMapUpgradeResearchCost(level);
|
||||
const points = state.researchPoints ?? 0;
|
||||
if (points < cost) return [false, "NotEnoughResearch"];
|
||||
state.researchPoints = points - cost;
|
||||
state.worldMapLevel = level + 1;
|
||||
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
|
||||
return [true, undefined];
|
||||
}
|
||||
Reference in New Issue
Block a user