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:
231
web/js/reproduction.js
Normal file
231
web/js/reproduction.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Reproduction: pairs of same-type animals (at least one from another zoo) in proximity
|
||||
* produce a baby after a delay. Delay is reduced by zoo reproduction score and biome/temperature fit.
|
||||
*/
|
||||
|
||||
import { GameConfig } from "./config.js";
|
||||
import { LootTables } from "./loot-tables.js";
|
||||
import { cellKey, isOriginCell } from "./grid-utils.js";
|
||||
import { getBlockKeysFromCell } from "./placement.js";
|
||||
import { getDisplayBiome, getDisplayTemperature } from "./biome-rules.js";
|
||||
import { addPendingBaby } from "./zoo.js";
|
||||
|
||||
/**
|
||||
* Zoo reproduction score (stub for phase 7). Higher = shorter delay until baby.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getReproductionScore(state) {
|
||||
const birthCount = state.birthCount ?? 0;
|
||||
const feedingRate = state.feedingRate ?? 1;
|
||||
return Math.max(0.5, 1 + birthCount * 0.05 + feedingRate * 0.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduction factor from animal's fit to cell biome (from loot-tables).
|
||||
* @param {import("./loot-tables.js").LootTables["Animals"][string]} def
|
||||
* @param {string} cellBiome
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getBiomeReproductionFactor(def, cellBiome) {
|
||||
if (!def || !def.reproductionScoreByBiome) return 0.5;
|
||||
return def.reproductionScoreByBiome[cellBiome] ?? 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperature factor: 1 when within ideal ± tolerance, else reduced.
|
||||
* @param {import("./loot-tables.js").LootTables["Animals"][string]} def
|
||||
* @param {number} displayTemp
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getTemperatureFactor(def, displayTemp) {
|
||||
const ideal = def?.idealTemperature ?? 18;
|
||||
const tolerance = def?.temperatureTolerance ?? 5;
|
||||
const dist = Math.abs(displayTemp - ideal);
|
||||
if (dist <= tolerance) return 1;
|
||||
return Math.max(0.3, 1 - 0.2 * (dist / tolerance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Neighbor keys (edge-adjacent) for a cell key "x_y". Does not check bounds.
|
||||
* @param {string} key
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getNeighborKeys(key) {
|
||||
const m = key.match(/^(\d+)_(\d+)$/);
|
||||
if (!m) return [];
|
||||
const x = Number(m[1]);
|
||||
const y = Number(m[2]);
|
||||
return [cellKey(x - 1, y), cellKey(x + 1, y), cellKey(x, y - 1), cellKey(x, y + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the two blocks (by origin key) are adjacent (any cell of one touches any cell of the other).
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {string} keyA origin key "ox_oy"
|
||||
* @param {string} keyB origin key "ox_oy"
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function blocksAreAdjacent(state, keyA, keyB) {
|
||||
const m1 = keyA.match(/^(\d+)_(\d+)$/);
|
||||
const m2 = keyB.match(/^(\d+)_(\d+)$/);
|
||||
if (!m1 || !m2) return false;
|
||||
const setA = new Set(getBlockKeysFromCell(state, Number(m1[1]), Number(m1[2])));
|
||||
const setB = new Set(getBlockKeysFromCell(state, Number(m2[1]), Number(m2[2])));
|
||||
for (const k of setA) {
|
||||
for (const neighbor of getNeighborKeys(k)) {
|
||||
if (setB.has(neighbor)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 cells = state.grid.cells;
|
||||
const origins = [];
|
||||
for (const [key, cell] of Object.entries(cells)) {
|
||||
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) {
|
||||
const def = LootTables.Animals[cell.id];
|
||||
if (def !== null && def !== undefined) {
|
||||
origins.push({
|
||||
key,
|
||||
animalId: cell.id,
|
||||
fromOtherZoo: cell.fromOtherZoo === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const pairs = [];
|
||||
for (let i = 0; i < origins.length; i++) {
|
||||
for (let j = i + 1; j < origins.length; j++) {
|
||||
const a = origins[i];
|
||||
const b = origins[j];
|
||||
if (a.animalId === b.animalId && (a.fromOtherZoo || b.fromOtherZoo) && blocksAreAdjacent(state, a.key, b.key)) {
|
||||
const keyA = a.key < b.key ? a.key : b.key;
|
||||
const keyB = a.key < b.key ? b.key : a.key;
|
||||
pairs.push({ keyA, keyB, animalId: a.animalId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique pair key for deduplication.
|
||||
* @param {string} keyA
|
||||
* @param {string} keyB
|
||||
* @returns {string}
|
||||
*/
|
||||
function pairKey(keyA, keyB) {
|
||||
return keyA < keyB ? `${keyA},${keyB}` : `${keyB},${keyA}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process due reproduction timers: add baby or sale listing, remove timer.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {number} nowUnix
|
||||
* @param {Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>} timers
|
||||
* @param {number} index
|
||||
*/
|
||||
function processDueTimer(state, nowUnix, timers, index) {
|
||||
const t = timers[index];
|
||||
if (t.dueAt > nowUnix) return;
|
||||
const [ok, result] = addPendingBaby(state, t.animalId, false);
|
||||
if (ok) {
|
||||
state.birthCount = (state.birthCount ?? 0) + 1;
|
||||
} else if (result === "NoFreeNursery") {
|
||||
state.saleListings = state.saleListings ?? [];
|
||||
const listingId = `sale_${state.nextTokenId}`;
|
||||
state.nextTokenId += 1;
|
||||
state.saleListings.push({
|
||||
id: listingId,
|
||||
zooId: state.myZooId ?? "player",
|
||||
animalId: t.animalId,
|
||||
isBaby: true,
|
||||
price: 50,
|
||||
endAt: nowUnix + 3600,
|
||||
reproductionScoreAtSale: getReproductionScore(state),
|
||||
});
|
||||
state.birthCount = (state.birthCount ?? 0) + 1;
|
||||
}
|
||||
timers.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove timers whose cells are no longer valid animals.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {Array<{ keyA: string, keyB: string }>} timers
|
||||
*/
|
||||
function pruneInvalidTimers(state, timers) {
|
||||
const cells = state.grid.cells;
|
||||
for (let i = timers.length - 1; i >= 0; i--) {
|
||||
const t = timers[i];
|
||||
const cellA = cells[t.keyA];
|
||||
const cellB = cells[t.keyB];
|
||||
if (!cellA || cellA.kind !== "animal" || !cellB || cellB.kind !== "animal") {
|
||||
timers.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new reproduction pairs to timers with dueAt.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {number} nowUnix
|
||||
* @param {Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>} timers
|
||||
* @param {Set<string>} existingSet
|
||||
*/
|
||||
function addNewPairsToTimers(state, nowUnix, timers, existingSet) {
|
||||
const baseSeconds = GameConfig.Reproduction?.BaseSeconds ?? 60;
|
||||
const currentPairs = findReproductionPairs(state);
|
||||
const score = getReproductionScore(state);
|
||||
const grid = state.grid;
|
||||
for (const { keyA, keyB, animalId } of currentPairs) {
|
||||
const pk = pairKey(keyA, keyB);
|
||||
if (existingSet.has(pk)) {
|
||||
// skip already tracked pair
|
||||
} else {
|
||||
const def = LootTables.Animals[animalId];
|
||||
if (def !== null && def !== undefined) {
|
||||
const m1 = keyA.match(/^(\d+)_(\d+)$/);
|
||||
const m2 = keyB.match(/^(\d+)_(\d+)$/);
|
||||
if (m1 && m2) {
|
||||
const biome1 = getDisplayBiome(Number(m1[1]), Number(m1[2]), grid);
|
||||
const biome2 = getDisplayBiome(Number(m2[1]), Number(m2[2]), grid);
|
||||
const temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid);
|
||||
const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid);
|
||||
const biomeFactor = (getBiomeReproductionFactor(def, biome1) + getBiomeReproductionFactor(def, biome2)) / 2;
|
||||
const tempFactor = (getTemperatureFactor(def, temp1) + getTemperatureFactor(def, temp2)) / 2;
|
||||
const factor = Math.max(0.2, score * biomeFactor * tempFactor);
|
||||
const delay = Math.max(5, baseSeconds / factor);
|
||||
timers.push({ keyA, keyB, animalId, dueAt: nowUnix + Math.floor(delay) });
|
||||
existingSet.add(pk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run reproduction tick: spawn babies for due timers, then register new pairs with dueAt.
|
||||
* @param {import("./types.js").GameState} state
|
||||
* @param {number} nowUnix
|
||||
*/
|
||||
export function tickReproduction(state, nowUnix) {
|
||||
const timers = state.reproductionTimers ?? [];
|
||||
|
||||
for (let i = timers.length - 1; i >= 0; i--) {
|
||||
processDueTimer(state, nowUnix, timers, i);
|
||||
}
|
||||
|
||||
const existingSet = new Set(timers.map((t) => pairKey(t.keyA, t.keyB)));
|
||||
pruneInvalidTimers(state, timers);
|
||||
addNewPairsToTimers(state, nowUnix, timers, existingSet);
|
||||
state.reproductionTimers = timers;
|
||||
}
|
||||
Reference in New Issue
Block a user