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:
2026-03-03 22:24:17 +01:00
commit e031c9a1d2
155 changed files with 22334 additions and 0 deletions

45
web/js/animal-visits.js Normal file
View File

@@ -0,0 +1,45 @@
/**
* Animals disappear after a duration without any visitor on their cell.
* Visitors are simulated at their current orbit position; cells under a visitor get lastVisitedAt updated.
* Death removal is handled by checkDeathCauses in food.js.
*/
import { getVisitorCount } from "./income.js";
import { getAttractionCenter, getVisitorPosition, getCellKeyFromPixelPosition } from "./visitor-attraction.js";
/**
* Update lastVisitedAt for animal cells when a visitor is on that cell.
* Does not remove animals; checkDeathCauses(state, nowUnix) handles death from no visit.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @param {number} nowMs
*/
export function tickAnimalVisits(state, nowUnix, nowMs) {
const gridWidth = state.grid.width;
const gridHeight = state.grid.height;
const cells = state.grid.cells;
const visitorCount = getVisitorCount(state);
if (visitorCount > 0) {
const { centerX, centerY } = getAttractionCenter(state, gridWidth, gridHeight);
const t = nowMs / 1000;
for (let i = 0; i < visitorCount; i++) {
const { px, py } = getVisitorPosition({
i,
n: visitorCount,
t,
centerX,
centerY,
gridWidth,
gridHeight,
});
const key = getCellKeyFromPixelPosition(px, py, gridWidth, gridHeight);
if (key !== "") {
const cell = cells[key];
if (cell !== null && cell !== undefined && cell.kind === "animal") {
cell.lastVisitedAt = nowUnix;
}
}
}
}
}

258
web/js/api-client.js Normal file
View File

@@ -0,0 +1,258 @@
/**
* API client: signed requests (Ed25519), load/save zoo state and world zoos.
*/
import { getOrCreateKeyPair, signMessage } from "./auth-client.js";
/**
* @returns {string} API base URL (from ?api=, window, or localStorage)
*/
export function getApiBase() {
if (typeof window === "undefined") return "";
let base = "";
if (window.BUILAZOO_API_URL !== undefined && window.BUILAZOO_API_URL !== null && window.BUILAZOO_API_URL !== "") {
base = String(window.BUILAZOO_API_URL);
} else {
try {
const stored = localStorage.getItem("builazoo_api_url");
if (stored && stored.trim() !== "") base = stored.trim();
} catch (_) {
// ignore localStorage access errors
}
}
return base ? base.replace(/\/+$/, "") : "";
}
/**
* Persist API base URL so it is used on next load (getApiBase reads it from localStorage).
* @param {string} url
*/
export function setApiBaseUrl(url) {
const raw = url ? String(url).trim() : "";
const trimmed = raw ? raw.replace(/\/+$/, "") : "";
if (typeof window !== "undefined") window.BUILAZOO_API_URL = trimmed || undefined;
try {
if (trimmed) localStorage.setItem("builazoo_api_url", trimmed);
else localStorage.removeItem("builazoo_api_url");
} catch (_) {
// ignore localStorage errors
}
}
/**
* @param {string} body
* @returns {Promise<string>} hex
*/
async function hashBody(body) {
const enc = new TextEncoder().encode(body || "");
const buf = await crypto.subtle.digest("SHA-256", enc);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* @param {string} method
* @param {string} path
* @param {string} [body]
* @returns {Promise<Response>}
*/
export async function signedFetch(method, path, body) {
const base = getApiBase();
if (!base) throw new Error("No API base URL");
const url = base + path;
const timestamp = new Date().toISOString();
const bodyStr = body !== null && body !== undefined ? (typeof body === "string" ? body : JSON.stringify(body)) : "";
const bodyHash = await hashBody(bodyStr);
const message = `${method}\n${path}\n${timestamp}\n${bodyHash}`;
const keys = await getOrCreateKeyPair();
if (!keys) throw new Error("No keypair");
const signature = await signMessage(keys.privateKey, message);
const headers = {
"Content-Type": "application/json",
"X-Public-Key": keys.publicKeyBase64,
"X-Signature": signature,
"X-Timestamp": timestamp,
};
const init = { method, headers };
if (bodyStr) init.body = bodyStr;
return fetch(url, init);
}
/**
* @returns {Promise<{ worldZoos: Array<{ id: string, name: string, x: number, y: number, animalWeights: object }>, mapWidth: number, mapHeight: number }>}
*/
export async function loadZoos() {
const base = getApiBase();
if (!base) return { worldZoos: [], mapWidth: 100, mapHeight: 100 };
const res = await fetch(base + "/api/zoos");
if (!res.ok) throw new Error(`loadZoos ${res.status}`);
const data = await res.json();
return {
worldZoos: data.worldZoos || [],
mapWidth: data.mapWidth ?? 100,
mapHeight: data.mapHeight ?? 100,
};
}
/**
* Load current user's zoo. Distinguishes 401 (account unknown → register) from 404 (no zoo → create zoo).
* @returns {Promise<{ ok: true, data: { zooId: string, name: string, x: number, y: number, game_state: object | null } } | { status: 401 } | { status: 404 }>}
*/
export async function loadMyZoo() {
const base = getApiBase();
if (!base) return { status: 404 };
const res = await signedFetch("GET", "/api/zoos/me");
if (res.status === 401) return { status: 401 };
if (res.status === 404) return { status: 404 };
if (!res.ok) throw new Error(`loadMyZoo ${res.status}`);
const data = await res.json();
return { ok: true, data };
}
/**
* @param {object} gameState
* @returns {Promise<void>}
*/
export async function saveMyZoo(gameState) {
const base = getApiBase();
if (!base) return;
const res = await signedFetch("PATCH", "/api/zoos/me", { game_state: gameState });
if (!res.ok) throw new Error(`saveMyZoo ${res.status}`);
}
/**
* @param {string} pseudo
* @returns {Promise<{ id: string, pseudo: string }>}
*/
export async function register(pseudo) {
const base = getApiBase();
if (!base) throw new Error("No API base URL");
const keys = await getOrCreateKeyPair();
if (!keys) throw new Error("No keypair");
const res = await fetch(base + "/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Public-Key": keys.publicKeyBase64,
},
body: JSON.stringify({ pseudo: pseudo.trim() }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `register ${res.status}`);
}
return res.json();
}
/**
* @param {string} name
* @param {object} gameState
* @returns {Promise<{ zooId: string, name: string, x: number, y: number }>}
*/
export async function createMyZoo(name, gameState) {
const res = await signedFetch("POST", "/api/zoos/me", { name, game_state: gameState });
if (!res.ok) throw new Error(`createMyZoo ${res.status}`);
return res.json();
}
/**
* Fetch sales: with auth returns { asSeller, asBuyerUndelivered, active }; without auth only { active }.
* @returns {Promise<{ asSeller?: Array<object>, asBuyerUndelivered?: Array<object>, active: Array<object> }>}
*/
export async function getSales() {
const base = getApiBase();
if (!base) return { active: [] };
const res = await signedFetch("GET", "/api/sales");
if (!res.ok) throw new Error(`getSales ${res.status}`);
return res.json();
}
/**
* Map a server sale listing (asSeller item) to client SaleListing shape.
* @param {{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: string, status?: string, best_bid_amount?: number, best_bidder_zoo_id?: string, sold_at?: string, validated_at?: string | null, reproduction_score_at_sale?: number }} s
* @returns {import("./types.js").SaleListing}
*/
export function mapServerListingToClient(s) {
return {
id: s.id,
zooId: s.seller_zoo_id,
animalId: s.animal_id,
isBaby: s.is_baby,
price: s.initial_price,
endAt: Math.floor(new Date(s.end_at).getTime() / 1000),
status: s.status,
bestBidAmount: s.best_bid_amount,
bestBidderZooId: s.best_bidder_zoo_id,
validatedAt: s.validated_at ? Math.floor(new Date(s.validated_at).getTime() / 1000) : null,
reproductionScoreAtSale: s.reproduction_score_at_sale,
serverId: s.id,
};
}
/**
* Create a sale listing. Body: animalId, isBaby, price, endAt, reproductionScoreAtSale?.
* @param {{ animalId: string, isBaby: boolean, price: number, endAt: string, reproductionScoreAtSale?: number }} payload
* @returns {Promise<{ id: string }>}
*/
export async function createSale(payload) {
const res = await signedFetch("POST", "/api/sales", payload);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `createSale ${res.status}`);
}
return res.json();
}
/**
* Place a bid on a listing.
* @param {string} listingId
* @param {number} amount
* @returns {Promise<void>}
*/
export async function placeBid(listingId, amount) {
const res = await signedFetch("POST", `/api/sales/${listingId}/bid`, { amount });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `placeBid ${res.status}`);
}
}
/**
* Seller accepts the best bid.
* @param {string} listingId
* @returns {Promise<void>}
*/
export async function acceptSale(listingId) {
const res = await signedFetch("POST", `/api/sales/${listingId}/accept`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `acceptSale ${res.status}`);
}
}
/**
* Seller rejects the current best bid.
* @param {string} listingId
* @returns {Promise<void>}
*/
export async function rejectSale(listingId) {
const res = await signedFetch("POST", `/api/sales/${listingId}/reject`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `rejectSale ${res.status}`);
}
}
/**
* Buyer marks a won listing as delivered (after adding baby/animal to zoo).
* @param {string} listingId
* @returns {Promise<void>}
*/
export async function deliverSale(listingId) {
const res = await signedFetch("POST", `/api/sales/${listingId}/deliver`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `deliverSale ${res.status}`);
}
}

245
web/js/audio.js Normal file
View File

@@ -0,0 +1,245 @@
let audioCtx = null;
function getCtx() {
if (audioCtx === null || audioCtx === undefined) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
function applySoundUpgrade(osc, now) {
osc.frequency.setValueAtTime(659, now);
osc.frequency.setValueAtTime(784, now + 0.06);
}
function applySoundHatch(osc, now) {
osc.type = "sine";
osc.frequency.setValueAtTime(392, now);
osc.frequency.setValueAtTime(523, now + 0.08);
osc.frequency.setValueAtTime(659, now + 0.16);
}
function applySoundPlace(osc, now) {
osc.type = "sine";
osc.frequency.setValueAtTime(659, now);
osc.frequency.setValueAtTime(523, now + 0.05);
osc.frequency.setValueAtTime(784, now + 0.1);
}
function applySoundBuy(osc, now) {
osc.type = "sine";
osc.frequency.setValueAtTime(523, now);
osc.frequency.setValueAtTime(659, now + 0.05);
osc.frequency.setValueAtTime(784, now + 0.1);
}
function applySoundSell(osc, now) {
osc.type = "triangle";
osc.frequency.setValueAtTime(440, now);
osc.frequency.setValueAtTime(349, now + 0.06);
osc.frequency.setValueAtTime(262, now + 0.12);
}
function applySoundPlotUpgrade(osc, now) {
osc.type = "sine";
osc.frequency.setValueAtTime(330, now);
osc.frequency.setValueAtTime(440, now + 0.06);
osc.frequency.setValueAtTime(554, now + 0.12);
}
function applySoundTruckUpgrade(osc, now) {
osc.type = "square";
osc.frequency.setValueAtTime(220, now);
osc.frequency.setValueAtTime(277, now + 0.05);
osc.frequency.setValueAtTime(330, now + 0.1);
}
function applySoundSchoolUpgrade(osc, now) {
osc.type = "sine";
osc.frequency.setValueAtTime(523, now);
osc.frequency.setValueAtTime(659, now + 0.05);
osc.frequency.setValueAtTime(784, now + 0.1);
osc.frequency.setValueAtTime(1047, now + 0.15);
}
function applySoundWorldMapUpgrade(osc, now) {
osc.type = "sine";
osc.frequency.setValueAtTime(440, now);
osc.frequency.setValueAtTime(554, now + 0.06);
osc.frequency.setValueAtTime(698, now + 0.12);
}
function applySoundQuest(osc, now) {
osc.frequency.setValueAtTime(659, now);
osc.frequency.setValueAtTime(784, now + 0.06);
}
function applySoundError(osc, now) {
osc.frequency.setValueAtTime(200, now);
osc.frequency.setValueAtTime(180, now + 0.08);
}
function applySoundDefault(osc, now) {
osc.frequency.setValueAtTime(440, now);
}
const SOUND_APPLIERS = {
upgrade: applySoundUpgrade,
hatch: applySoundHatch,
place: applySoundPlace,
buy: applySoundBuy,
sell: applySoundSell,
plotUpgrade: applySoundPlotUpgrade,
truckUpgrade: applySoundTruckUpgrade,
schoolUpgrade: applySoundSchoolUpgrade,
worldMapUpgrade: applySoundWorldMapUpgrade,
quest: applySoundQuest,
error: applySoundError,
};
/**
* @param {string} type One of: hatch, place, buy, sell, plotUpgrade, truckUpgrade, schoolUpgrade, worldMapUpgrade, upgrade, quest, error
* @returns {void}
*/
export function playSound(type) {
try {
const ctx = getCtx();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
osc.start(now);
osc.stop(now + 0.15);
const applier = SOUND_APPLIERS[type] || applySoundDefault;
applier(osc, now);
} catch (e) {
console.warn("playSound failed", e);
}
}
let musicEnabled = false;
let musicIntervalId = null;
let musicGetState = () => null;
const MUSIC_BEAT_MS = 400;
/**
* Register state getter so music can switch by day/night/truck.
* @param {() => { timeOfDay?: number, truckSale?: { startAt: number } }} getState
*/
export function setMusicGetState(getState) {
musicGetState = getState || (() => null);
}
function isNight(timeOfDay) {
const t = (timeOfDay ?? 6) % 24;
return t < 6 || t >= 20;
}
function isTruckMoving(state) {
const sale = state.truckSale;
if (!sale || !sale.startAt) return false;
const truckMs = 2500;
return (Date.now() - sale.startAt) < truckMs;
}
function playMusicVoliere() {
if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return;
try {
const ctx = getCtx();
const now = ctx.currentTime;
const gain = ctx.createGain();
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.04, now + 0.02);
gain.gain.exponentialRampToValueAtTime(0.004, now + 0.25);
const freq = 600 + Math.random() * 800;
const osc = ctx.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
osc.connect(gain);
osc.start(now);
osc.stop(now + 0.25);
} catch (e) {
console.warn("playMusicVoliere failed", e);
}
}
function playMusicBrahms() {
if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return;
try {
const ctx = getCtx();
const now = ctx.currentTime;
const gain = ctx.createGain();
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.05, now + 0.03);
gain.gain.exponentialRampToValueAtTime(0.005, now + 0.5);
const waltz = [415.3, 523.25, 622.25];
waltz.forEach((freq, i) => {
const osc = ctx.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now + i * 0.15);
osc.connect(gain);
osc.start(now + i * 0.15);
osc.stop(now + 0.5);
});
} catch (e) {
console.warn("playMusicBrahms failed", e);
}
}
function playMusicTrepak() {
if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return;
try {
const ctx = getCtx();
const now = ctx.currentTime;
const gain = ctx.createGain();
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.08, now + 0.02);
gain.gain.exponentialRampToValueAtTime(0.006, now + 0.12);
const osc = ctx.createOscillator();
osc.type = "triangle";
osc.frequency.setValueAtTime(280, now);
osc.frequency.setValueAtTime(350, now + 0.06);
osc.connect(gain);
osc.start(now);
osc.stop(now + 0.12);
} catch (e) {
console.warn("playMusicTrepak failed", e);
}
}
function playMusicTick() {
if (!musicEnabled || (audioCtx === null || audioCtx === undefined)) return;
const state = musicGetState();
if (state && isTruckMoving(state)) {
playMusicTrepak();
return;
}
const timeOfDay = state && state.timeOfDay !== null && state.timeOfDay !== undefined ? state.timeOfDay : 6;
if (isNight(timeOfDay)) {
playMusicBrahms();
} else {
playMusicVoliere();
}
}
/** Call to enable/disable background music (procedural: Volière / Brahms / Trepak). */
export function setMusicEnabled(enabled) {
if (musicIntervalId !== null && musicIntervalId !== undefined) {
clearInterval(musicIntervalId);
musicIntervalId = null;
}
musicEnabled = Boolean(enabled);
if (musicEnabled) {
getCtx();
playMusicTick();
musicIntervalId = setInterval(playMusicTick, MUSIC_BEAT_MS);
}
}
export function isMusicEnabled() {
return musicEnabled;
}

100
web/js/auth-client.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* Ed25519 keypair: generate, store in localStorage, sign requests.
* Public key exported as SPKI base64url for server verification.
*/
const STORAGE_KEY_PUBLIC = "builazoo_public_key";
const STORAGE_KEY_PRIVATE = "builazoo_private_key";
/**
* @returns {Promise<CryptoKeyPair | null>}
*/
function generateKeyPair() {
return crypto.subtle.generateKey(
{ name: "Ed25519" },
true,
["sign", "verify"]
);
}
/**
* @param {ArrayBuffer} buf
* @returns {string}
*/
function base64url(buf) {
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* @returns {Promise<{ publicKeyBase64: string, privateKey: CryptoKey } | null>}
*/
export async function getOrCreateKeyPair() {
if (typeof crypto === "undefined" || !crypto.subtle) {
throw new Error("Connexion sécurisée requise (HTTPS ou localhost) pour créer un compte.");
}
try {
const pubRaw = localStorage.getItem(STORAGE_KEY_PUBLIC);
const privRaw = localStorage.getItem(STORAGE_KEY_PRIVATE);
if (pubRaw && privRaw) {
const privateKey = await crypto.subtle.importKey(
"pkcs8",
base64urlDecodeToBuf(privRaw),
{ name: "Ed25519" },
true,
["sign"]
);
await crypto.subtle.importKey(
"spki",
base64urlDecodeToBuf(pubRaw),
{ name: "Ed25519" },
true,
["verify"]
);
return { publicKeyBase64: pubRaw, privateKey };
}
const pair = await generateKeyPair();
const [pubExported, privExported] = await Promise.all([
crypto.subtle.exportKey("spki", pair.publicKey),
crypto.subtle.exportKey("pkcs8", pair.privateKey),
]);
localStorage.setItem(STORAGE_KEY_PUBLIC, base64url(pubExported));
localStorage.setItem(STORAGE_KEY_PRIVATE, base64url(privExported));
return { publicKeyBase64: base64url(pubExported), privateKey: pair.privateKey };
} catch (e) {
console.warn("getOrCreateKeyPair failed", e);
return null;
}
}
/**
* @param {string} str base64url
* @returns {ArrayBuffer}
*/
function base64urlDecodeToBuf(str) {
const pad = (4 - (str.length % 4)) % 4;
const b64 = (str + "==".slice(0, pad)).replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(b64);
const buf = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
return buf.buffer;
}
/**
* @param {CryptoKey} privateKey
* @param {string} message
* @returns {Promise<string>} base64url signature
*/
export async function signMessage(privateKey, message) {
const enc = new TextEncoder().encode(message);
const sig = await crypto.subtle.sign("Ed25519", privateKey, enc);
return base64url(sig);
}
/**
* Call after register to ensure we have keys stored (already done by getOrCreateKeyPair).
* @returns {boolean} true if keys exist
*/
export function hasStoredKeys() {
return Boolean(localStorage.getItem(STORAGE_KEY_PUBLIC) && localStorage.getItem(STORAGE_KEY_PRIVATE));
}

View File

@@ -0,0 +1,102 @@
/**
* Auto-mode profiles: 50 specialisations in 5 families.
* Used by player auto mode and bot zoos (bots still use legacy random fast/slow/balanced).
* Params: spendThreshold (multiplier on cost to allow spend), upgradeChance (01), sellChance (01, bots only).
*/
/** @typedef {{ id: number, familyId: number, spendThreshold: number, upgradeChance: number, sellChance: number, labelKey: string, prioritiesKey: string, risksKey: string }} AutoModeProfile */
/** Families 15: Conservateurs, Éleveurs, Commerçants, Expansionnistes, Scientifiques. */
export const AUTO_MODE_FAMILY_IDS = [1, 2, 3, 4, 5];
/** Profile id range per family: 110, 1120, 2130, 3140, 4150. */
const FAMILY_RANGES = [[1, 10], [11, 20], [21, 30], [31, 40], [41, 50]];
/** Legacy profile name to default profile id (balanced=25, fast=33, slow=7). */
export const LEGACY_PROFILE_TO_ID = { balanced: 25, fast: 33, slow: 7 };
/**
* All 50 profiles. Params tuned per family.
* @type {AutoModeProfile[]}
*/
const PROFILES = [];
function buildProfiles() {
for (let familyId = 1; familyId <= 5; familyId++) {
const [minId, maxId] = FAMILY_RANGES[familyId - 1];
for (let id = minId; id <= maxId; id++) {
const i = (id - minId) / (maxId - minId);
const spendThreshold = 0.5 + i * 2;
const upgradeChance = 0.05 + i * 0.5;
const sellChance = familyId === 2 ? 0.02 + i * 0.08 : familyId === 3 ? 0.08 + i * 0.12 : 0.02 + i * 0.1;
PROFILES.push({
id,
familyId,
spendThreshold: Math.round(spendThreshold * 100) / 100,
upgradeChance: Math.round(upgradeChance * 100) / 100,
sellChance: Math.round(sellChance * 100) / 100,
labelKey: `autoProfile.specialisation.${id}`,
prioritiesKey: `autoProfile.priorities.${id}`,
risksKey: `autoProfile.risks.${id}`,
});
}
}
}
buildProfiles();
/**
* Resolve effective profile id from state (autoModeProfileId or legacy autoModeProfile).
* @param {{ autoModeProfileId?: number, autoModeProfile?: string }} state
* @returns {number}
*/
export function getEffectiveProfileId(state) {
const id = state.autoModeProfileId;
if (id !== null && id !== undefined && id >= 1 && id <= 50) return id;
const legacy = state.autoModeProfile;
if (legacy === "fast" || legacy === "slow" || legacy === "balanced") return LEGACY_PROFILE_TO_ID[legacy];
return LEGACY_PROFILE_TO_ID.balanced;
}
/**
* Get profile by id (150). Returns default balanced params if not found.
* @param {number} profileId
* @returns {AutoModeProfile}
*/
export function getProfileById(profileId) {
const p = PROFILES.find((x) => x.id === profileId);
if (p) return p;
const def = PROFILES.find((x) => x.id === 25);
return def ?? PROFILES[0];
}
/**
* Params for use in bot/player auto logic.
* @param {number} profileId
* @returns {{ spendThreshold: number, upgradeChance: number, sellChance: number }}
*/
export function getProfileParams(profileId) {
const p = getProfileById(profileId);
return {
spendThreshold: p.spendThreshold,
upgradeChance: p.upgradeChance,
sellChance: p.sellChance,
};
}
/**
* All profiles in the given family (15).
* @param {number} familyId
* @returns {AutoModeProfile[]}
*/
export function getProfilesByFamily(familyId) {
return PROFILES.filter((p) => p.familyId === familyId);
}
/** All 50 profiles.
* @returns {AutoModeProfile[]}
*/
export function getAllProfiles() {
return [...PROFILES];
}
export { FAMILY_RANGES };

124
web/js/biome-rules.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* Biomes: Meadow, Freshwater, Ocean, Forest, Mountain.
* Each cell has a biome and a temperature; display uses optional interpolation for smooth transitions.
*/
export const BIOMES = ["Meadow", "Freshwater", "Ocean", "Forest", "Mountain"];
/**
* Base biome from grid position (5 zones by column).
* @param {number} width
* @param {number} height
* @param {number} x 1-based column
* @param {number} y 1-based row
* @returns {string}
*/
export function getCellBiome(width, height, x, y) {
const w = Math.max(1, width);
const h = Math.max(1, height);
const col = Math.max(1, Math.min(w, Math.floor(x)));
const _row = Math.max(1, Math.min(h, Math.floor(y)));
const t = Math.floor((col - 1) / (w / 5));
const index = Math.min(4, Math.max(0, t));
return BIOMES[index] ?? "Meadow";
}
/**
* Backward-compat: getCellBiome with 2 args (width, x) returns Meadow/Ocean/Mountain by column thirds.
* @param {number} width
* @param {number} x
* @returns {string}
*/
export function getCellBiomeLegacy(width, x) {
const third = Math.max(1, Math.floor(width / 3));
if (x <= third) return "Meadow";
if (x <= third * 2) return "Ocean";
return "Mountain";
}
/**
* Base temperature at cell (smooth gradient by position). Range about 1028.
* @param {number} width
* @param {number} height
* @param {number} x 1-based
* @param {number} y 1-based
* @returns {number}
*/
export function getCellTemperature(width, height, x, y) {
const w = Math.max(1, width);
const h = Math.max(1, height);
const nx = (Math.max(1, Math.min(w, Math.floor(x))) - 0.5) / w;
const ny = (Math.max(1, Math.min(h, Math.floor(y))) - 0.5) / h;
return 10 + ny * 14 + nx * 4;
}
/**
* Display biome at (x, y) — for now the cell's own biome (no string interpolation).
* @param {number} x 1-based
* @param {number} y 1-based
* @param {{ width: number, height: number }} grid
* @returns {string}
*/
export function getDisplayBiome(x, y, grid) {
return getCellBiome(grid.width, grid.height, x, y);
}
/**
* Display temperature at (x, y) with smooth interpolation from neighbours.
* @param {number} x 1-based
* @param {number} y 1-based
* @param {{ width: number, height: number }} grid
* @returns {number}
*/
export function getDisplayTemperature(x, y, grid) {
const w = grid.width;
const h = grid.height;
let sum = 0;
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 1 && nx <= w && ny >= 1 && ny <= h) {
sum += getCellTemperature(w, h, nx, ny);
count += 1;
}
}
}
return count > 0 ? sum / count : getCellTemperature(w, h, x, y);
}
/**
* Temperature band for CSS class: low (< 16), mid (1622), high (> 22).
* @param {number} temp
* @returns {"low"|"mid"|"high"}
*/
export function getTemperatureBand(temp) {
if (temp < 16) return "low";
if (temp <= 22) return "mid";
return "high";
}
/**
* @param {string} animalBiome
* @param {string} cellBiome
* @returns {boolean}
*/
export function isAnimalAllowedOnBiome(animalBiome, cellBiome) {
if (animalBiome === cellBiome) return true;
if (animalBiome === "Meadow" && cellBiome === "Forest") return true;
if (animalBiome === "Ocean" && cellBiome === "Freshwater") return true;
return false;
}
/**
* Biomes that are compatible with this cell for loot/placement (cell biome + compatible animal biomes).
* @param {string} cellBiome
* @returns {string[]}
*/
export function getBiomesCompatibleWithCell(cellBiome) {
const set = [cellBiome];
if (cellBiome === "Forest") set.push("Meadow");
if (cellBiome === "Freshwater") set.push("Ocean");
return set;
}

331
web/js/bot-zoo.js Normal file
View File

@@ -0,0 +1,331 @@
/**
* Bot zoos: same indicators and formulas as player (coins, plotLevel, conveyorLevel, truckLevel).
* Decisions (buy, sell, upgrade) follow a randomly chosen profile (fast / slow / balanced).
* Egg color choice is weighted by neighboring zoos' animalWeights.
*/
import { GameConfig } from "./config.js";
import { LootTables, getColorNames, zeroAnimalWeights } from "./loot-tables.js";
import { getPlotUpgradeCost, getSchoolUpgradeCost, getTruckUpgradeCost, getConveyorUpgradeCost, getSellValue } from "./economy.js";
import { getIncomeMultiplier } from "./mutation-rules.js";
import { pickId } from "./weighted-random.js";
import { tryUpgradePlot } from "./zoo.js";
import { tryUpgrade, tryUpgradeTruck } from "./conveyor.js";
import { getEffectiveProfileId, getProfileParams, LEGACY_PROFILE_TO_ID } from "./auto-mode-profiles.js";
const PROFILE_OPTIONS = ["fast", "slow", "balanced"];
/**
* @returns {import("./types.js").BotState}
*/
export function createInitialBotState() {
const cfg = GameConfig.Bots || {};
const minC = cfg.InitialCoinsMin ?? 150;
const maxC = cfg.InitialCoinsMax ?? 450;
const coins = minC + Math.floor(Math.random() * (maxC - minC + 1));
const profile = PROFILE_OPTIONS[Math.floor(Math.random() * PROFILE_OPTIONS.length)];
return {
coins,
plotLevel: 1,
conveyorLevel: 1,
truckLevel: 1,
profile,
lastTickAt: 0,
};
}
/**
* @param {import("./types.js").WorldZooEntry} zoo
* @returns {number}
*/
export function getZooSkillLevel(zoo) {
const b = zoo.botState;
return b ? b.conveyorLevel : 1;
}
/**
* Neighbor color weights (sum of animalWeights of zoos within maxDistance on the map).
* @param {import("./types.js").GameState} state
* @param {string} zooId
* @returns {Record<string, number>}
*/
export function getNeighborColorWeights(state, zooId) {
const zoos = state.worldZoos ?? [];
const self = zoos.find((z) => z.id === zooId);
if (!self) return {};
const maxD = (GameConfig.Bots && GameConfig.Bots.NeighborMaxDistance) ?? 35;
const colorNames = getColorNames();
const out = zeroAnimalWeights();
for (const z of zoos) {
if (z.id !== zooId) {
const dx = (z.x - self.x) / 100;
const dy = (z.y - self.y) / 100;
const dist = Math.sqrt(dx * dx + dy * dy) * 100;
if (dist <= maxD) {
const w = z.animalWeights ?? {};
for (const c of colorNames) out[c] = (out[c] ?? 0) + (w[c] ?? 0);
}
}
}
return out;
}
function getAverageIncomePerSecondForEggType(eggType) {
const def = LootTables.EggTypes[eggType];
if (!def || !def.loot.length) return 0;
let sum = 0;
let totalWeight = 0;
for (const e of def.loot) {
const a = LootTables.Animals[e.id];
if (a) {
const w = e.weight ?? 1;
sum += a.baseIncomePerSecond * w;
totalWeight += w;
}
}
return totalWeight > 0 ? sum / totalWeight : 0;
}
function getAverageSellValueForEggType(eggType) {
const def = LootTables.EggTypes[eggType];
if (!def || !def.loot.length) return 0;
let sum = 0;
let totalWeight = 0;
const mutMult = getIncomeMultiplier("none");
for (const e of def.loot) {
const a = LootTables.Animals[e.id];
if (a) {
const w = e.weight ?? 1;
const v = getSellValue(a.baseIncomePerSecond, 1, mutMult, a.sellFactor);
sum += v * w;
totalWeight += w;
}
}
return totalWeight > 0 ? Math.floor(sum / totalWeight) : 0;
}
/**
* Add income for a bot from its animalWeights (same formula as player: income per "animal" per color).
* @param {import("./types.js").WorldZooEntry} zoo
* @param {number} dt seconds
*/
function tickBotIncome(zoo, dt) {
const b = zoo.botState;
if (!b) return;
const weights = zoo.animalWeights ?? {};
const colorNames = getColorNames();
let total = 0;
for (const eggType of colorNames) {
const count = weights[eggType] ?? 0;
if (count > 0) {
const avgIncome = getAverageIncomePerSecondForEggType(eggType);
total += count * avgIncome * dt;
}
}
const visitorPart = 0;
b.coins = Math.max(0, b.coins + total + visitorPart);
}
/**
* Ensure zoo has botState (for non-player zoos). Mutates zoo.
* @param {import("./types.js").WorldZooEntry} zoo
* @param {boolean} isPlayer
*/
export function ensureBotState(zoo, isPlayer) {
if (isPlayer) return;
if (zoo.botState) return;
zoo.botState = createInitialBotState();
}
/**
* @param {import("./types.js").GameState} state
* @param {import("./types.js").WorldZooEntry} zoo
* @param {{ b: import("./types.js").BotState, rng: () => number, params: { spendThreshold: number, upgradeChance: number } }} opts
* @returns {boolean}
*/
function botDecideUpgrade(state, zoo, opts) {
const { b, rng, params } = opts;
const { spendThreshold, upgradeChance } = params;
const plotCost = getPlotUpgradeCost(b.plotLevel);
const plotMax = GameConfig.Plot?.MaxLevel ?? 8;
const skillCost = getSchoolUpgradeCost(b.conveyorLevel);
const skillMax = GameConfig.Conveyor?.MaxLevel ?? 8;
const truckCost = getTruckUpgradeCost(b.truckLevel);
const truckMax = (GameConfig.Truck && GameConfig.Truck.MaxLevel) ?? 5;
const canUpgradePlot = b.plotLevel < plotMax && b.coins >= plotCost * spendThreshold;
const canUpgradeSkill = b.conveyorLevel < skillMax && b.coins >= skillCost * spendThreshold;
const canUpgradeTruck = b.truckLevel < truckMax && b.coins >= truckCost * spendThreshold;
const upgradeChoices = [];
if (canUpgradePlot) upgradeChoices.push("plot");
if (canUpgradeSkill) upgradeChoices.push("skill");
if (canUpgradeTruck) upgradeChoices.push("truck");
if (upgradeChoices.length === 0 || rng() >= upgradeChance) return false;
const choice = upgradeChoices[Math.floor(rng() * upgradeChoices.length)];
if (choice === "plot" && b.coins >= plotCost) {
b.coins -= plotCost;
b.plotLevel += 1;
return true;
}
if (choice === "skill" && b.coins >= skillCost) {
b.coins -= skillCost;
b.conveyorLevel += 1;
return true;
}
if (choice === "truck" && b.coins >= truckCost) {
b.coins -= truckCost;
b.truckLevel += 1;
return true;
}
return false;
}
/**
* @param {import("./types.js").WorldZooEntry} zoo
* @param {() => number} rng
* @param {{ sellChance: number }} params
* @returns {boolean}
*/
function botDecideSell(zoo, rng, params) {
const weights = zoo.animalWeights ?? {};
const colorNames = getColorNames();
const totalAnimals = colorNames.reduce((s, c) => s + (weights[c] ?? 0), 0);
if (totalAnimals <= 0 || rng() >= params.sellChance) return false;
const withWeight = colorNames.filter((c) => (weights[c] ?? 0) > 0).map((c) => ({ id: c, weight: weights[c] ?? 0 }));
if (withWeight.length === 0) return false;
const soldColor = pickId(rng, withWeight);
weights[soldColor] = (weights[soldColor] ?? 1) - 1;
if (weights[soldColor] <= 0) delete weights[soldColor];
const b = zoo.botState;
if (b) b.coins += getAverageSellValueForEggType(soldColor);
return true;
}
/**
* @param {import("./types.js").GameState} state
* @param {import("./types.js").WorldZooEntry} zoo
* @param {() => number} rng
* @param {{ spendThreshold: number }} params
* @returns {void}
*/
function botDecideBuy(state, zoo, rng, params) {
const b = zoo.botState;
if (!b) return;
const spendThreshold = params.spendThreshold;
const colorNames = getColorNames();
const neighborWeights = getNeighborColorWeights(state, zoo.id);
const weights = zoo.animalWeights ?? {};
const eligibleTypes = colorNames.filter((c) => {
const def = LootTables.EggTypes[c];
return def && b.conveyorLevel >= def.minConveyorLevel;
});
if (eligibleTypes.length === 0) return;
const neighborEntries = eligibleTypes.map((c) => ({
id: c,
weight: Math.max(1, (neighborWeights[c] ?? 0) + (weights[c] ?? 0) * 2),
}));
const totalN = neighborEntries.reduce((s, e) => s + e.weight, 0);
if (totalN <= 0) return;
const eggType = pickId(rng, neighborEntries);
const eggDef = LootTables.EggTypes[eggType];
if (!eggDef || b.coins < eggDef.price * spendThreshold) return;
b.coins -= eggDef.price;
weights[eggType] = (weights[eggType] ?? 0) + 1;
}
/**
* One decision tick for one bot: possibly buy egg, sell "animal", or upgrade.
* @param {import("./types.js").GameState} state
* @param {import("./types.js").WorldZooEntry} zoo
* @param {number} nowUnix
*/
function tickBotDecisions(state, zoo, nowUnix) {
const b = zoo.botState;
if (!b) return;
const cfg = GameConfig.Bots || {};
const minInterval = cfg.TickIntervalMinSeconds ?? 8;
const maxInterval = cfg.TickIntervalMaxSeconds ?? 25;
if (nowUnix - b.lastTickAt < minInterval) return;
const rng = () => Math.random();
const intervalRange = maxInterval - minInterval + 1;
const nextInterval = minInterval + Math.floor(rng() * intervalRange);
if (nowUnix - b.lastTickAt < nextInterval) return;
b.lastTickAt = nowUnix;
const profileId = LEGACY_PROFILE_TO_ID[b.profile] ?? 25;
const params = getProfileParams(profileId);
if (botDecideUpgrade(state, zoo, { b, rng, params })) return;
if (botDecideSell(zoo, rng, params)) return;
botDecideBuy(state, zoo, rng, params);
}
/**
* Run income and decision ticks for all bot zoos.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @param {number} dt seconds since last tick
*/
export function tickBotZoos(state, nowUnix, dt) {
const zoos = state.worldZoos ?? [];
for (const zoo of zoos) {
if (zoo.id !== "player") {
ensureBotState(zoo, false);
tickBotIncome(zoo, dt);
tickBotDecisions(state, zoo, nowUnix);
}
}
}
const PLAYER_AUTO_MIN_INTERVAL = 10;
const PLAYER_AUTO_MAX_INTERVAL = 28;
/**
* Apply one upgrade (plot, skill, truck) for player auto mode.
* @param {import("./types.js").GameState} state
* @param {{ spendThreshold: number, upgradeChance: number }} params
* @param {() => number} rng
* @returns {void}
*/
function playerAutoDoOneUpgrade(state, params, rng) {
const { spendThreshold, upgradeChance } = params;
const plotCost = getPlotUpgradeCost(state.plotLevel ?? 1);
const plotMax = GameConfig.Plot?.MaxLevel ?? 8;
const skillCost = getConveyorUpgradeCost(state.conveyorLevel ?? 1);
const skillMax = GameConfig.Conveyor?.MaxLevel ?? 8;
const truckCost = getTruckUpgradeCost(state.truckLevel ?? 1);
const truckMax = (GameConfig.Truck && GameConfig.Truck.MaxLevel) ?? 5;
const canPlot = (state.plotLevel ?? 1) < plotMax && state.coins >= plotCost * spendThreshold;
const canSkill = (state.conveyorLevel ?? 1) < skillMax && state.coins >= skillCost * spendThreshold;
const canTruck = (state.truckLevel ?? 1) < truckMax && state.coins >= truckCost * spendThreshold;
const choices = [];
if (canPlot) choices.push("plot");
if (canSkill) choices.push("skill");
if (canTruck) choices.push("truck");
if (choices.length === 0) return;
if (rng() >= upgradeChance) return;
const choice = choices[Math.floor(rng() * choices.length)];
if (choice === "plot") tryUpgradePlot(state);
else if (choice === "skill") tryUpgrade(state);
else if (choice === "truck") tryUpgradeTruck(state);
}
/**
* When auto mode is on, apply one bot-style upgrade decision for the player (plot, conveyor, or truck).
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {void}
*/
export function tickPlayerAutoMode(state, nowUnix) {
if (!state.autoMode) return;
const cfg = GameConfig.Bots || {};
const minInterval = cfg.TickIntervalMinSeconds ?? PLAYER_AUTO_MIN_INTERVAL;
const maxInterval = cfg.TickIntervalMaxSeconds ?? PLAYER_AUTO_MAX_INTERVAL;
const last = state.lastPlayerAutoTickAt ?? 0;
if (nowUnix - last < minInterval) return;
const rng = () => Math.random();
const nextInterval = minInterval + Math.floor(rng() * (maxInterval - minInterval + 1));
if (nowUnix - last < nextInterval) return;
state.lastPlayerAutoTickAt = nowUnix;
const profileId = getEffectiveProfileId(state);
const params = getProfileParams(profileId);
playerAutoDoOneUpgrade(state, params, rng);
}

236
web/js/config.js Normal file
View File

@@ -0,0 +1,236 @@
/** @type {{ DataStoreName: string, StateVersion: number, IncomeTickMs: number, SaveIntervalMs: number, Plot: object, Conveyor: object, Mutation: object, Events: object, Visitor: object, Time: object, Weather: object, Quests: object, Prestige: object }} */
export const GameConfig = {
DataStoreName: "BuildAZooWeb_v1",
StateVersion: 2,
/** Game loop interval (ms). Minimum 5000 to limit DB/API load. */
IncomeTickMs: 5000,
/** Save to localStorage/API at most every this many ms. */
SaveIntervalMs: 5000,
Plot: {
BaseWidth: 6,
BaseHeight: 6,
MaxLevel: 8,
ExpandByLevel: 2,
BaseUpgradeCost: 300,
UpgradeGrowth: 1.7,
},
Conveyor: {
MaxLevel: 8,
BaseUpgradeCost: 250,
UpgradeGrowth: 1.65,
OfferCount: 3,
/** Min seconds between conveyor offer refresh. >= 5 to limit load. */
RefreshSeconds: 8,
},
WorldMap: {
Zoos: [
{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: { Basic: 1, Ocean: 0, Mountain: 0 } },
{ id: "zoo_nord", name: "Zoo du Nord", x: 75, y: 15, animalWeights: { Basic: 2, Ocean: 1, Mountain: 1 } },
{ id: "zoo_est", name: "Zoo de l'Est", x: 85, y: 55, animalWeights: { Basic: 1, Ocean: 2, Mountain: 0 } },
{ id: "zoo_sud", name: "Zoo du Sud", x: 50, y: 85, animalWeights: { Basic: 1, Ocean: 1, Mountain: 2 } },
{ id: "zoo_ouest", name: "Zoo de l'Ouest", x: 15, y: 25, animalWeights: { Basic: 2, Ocean: 0, Mountain: 1 } },
],
Cities: [
{ id: "ville_nord", name: "Ville du Nord", x: 80, y: 10, maxVisitorsTowardZoos: 80 },
{ id: "ville_sud", name: "Ville du Sud", x: 45, y: 90, maxVisitorsTowardZoos: 80 },
{ id: "ville_centre", name: "Ville centrale", x: 50, y: 50, maxVisitorsTowardZoos: 100 },
],
Laboratory: { x: 50, y: 20, name: "Laboratoire" },
TruckAnimationMs: 2500,
NpcTruckIntervalMs: 8000,
MapUpgrade: {
MaxLevel: 5,
BaseUpgradeCost: 350,
UpgradeGrowth: 1.5,
/** Cost in research points per level (agrandissement carte). */
BaseResearchCost: 15,
ResearchUpgradeGrowth: 1.6,
},
},
Bots: {
/** Max distance (in map %) to consider a zoo as neighbor for color weights. */
NeighborMaxDistance: 35,
/** Min seconds between two decision ticks per bot. */
TickIntervalMinSeconds: 8,
TickIntervalMaxSeconds: 25,
/** Initial coins range for new bots. */
InitialCoinsMin: 150,
InitialCoinsMax: 450,
},
Truck: {
MaxLevel: 7,
BaseUpgradeCost: 400,
UpgradeGrowth: 1.5,
},
School: {
MaxLevel: 8,
BaseUpgradeCost: 250,
UpgradeGrowth: 1.65,
},
/** Centre de recherche (Rappel grandes règles): 7 niveaux, unités de recherche pour agrandir la carte, 10 zoos/unité. */
Research: {
MaxLevel: 7,
ZoosPerUnit: 10,
BuildCost: 300,
BaseUpgradeCost: 320,
UpgradeGrowth: 1.6,
PointsPerTickPerLevel: 0.1,
},
/** Billeterie: 7 niveaux, 20 visiteurs/unité en simultané. */
Billeterie: {
MaxLevel: 7,
VisitorsPerUnit: 20,
BuildCost: 280,
BaseUpgradeCost: 280,
UpgradeGrowth: 1.55,
},
/** Nourriture: 7 niveaux, 5 animaux/unité. */
Food: {
MaxLevel: 7,
AnimalsPerUnit: 5,
BuildCost: 260,
BaseUpgradeCost: 260,
UpgradeGrowth: 1.5,
/** Seconds without being fed before the animal dies. */
MaxSecondsWithoutFood: 120,
},
/** Accueil nouveaux animaux: 7 niveaux, 1 animal/unité. */
Reception: {
MaxLevel: 7,
AnimalsPerUnit: 1,
BuildCost: 240,
BaseUpgradeCost: 240,
UpgradeGrowth: 1.55,
AcclimatationSecondsBase: 45,
/** Seconds a ready animal can wait without being placed before dying. */
MaxSecondsReadyNotPlaced: 90,
},
/** Changement de milieu (couleur): 7 niveaux, payant. */
BiomeChangeColor: {
MaxLevel: 7,
BuildCost: 340,
BaseUpgradeCost: 350,
UpgradeGrowth: 1.6,
},
/** Changement de milieu (température): 7 niveaux, payant. */
BiomeChangeTemp: {
MaxLevel: 7,
BuildCost: 340,
BaseUpgradeCost: 350,
UpgradeGrowth: 1.6,
},
Mutation: {
BaseChance: 0.06,
},
Events: [],
Visitor: {
BasePaymentPerVisitor: 0.15,
VisitorsPerAnimal: 1.2,
PlotLevelBonus: 0.1,
StagnationDecayAfterSeconds: 60,
StagnationDecayPerMinute: 0.05,
CityAttractionScale: 0.002,
AnimalValueScale: 0.00015,
/** Seconds without any visitor on the cell before the animal disappears. */
MaxSecondsWithoutVisit: 300,
/** Multiplier bonus per souvenir shop level applied to payment per visitor (e.g. 0.2 = +20% per shop). */
SouvenirShopBonusPerShop: 0.2,
/** Chance per visitor to be a luxury guest (01). */
LuxuryGuestChance: 0.08,
/** Entry payment multiplier for luxury guests. */
LuxuryEntryMultiplier: 3,
/** Extra shop spending multiplier for luxury guests (applied on top of normal shop bonus). */
LuxuryShopMultiplier: 2.5,
/** Attractivity: penalty per recent death (subtracted from score). */
AttractivityDeathPenalty: 0.5,
/** Attractivity: bonus per birth (added to score). */
AttractivityBirthBonus: 0.2,
/** Extra stay time per souvenir shop level (e.g. 0.15 = +15% per level). Uses Time.DayLengthSeconds for base 1 day. */
StayMultiplierPerShopLevel: 0.15,
/** Extra stay time per distinct animal species (e.g. 0.02 = +2% per species). */
StayMultiplierPerSpecies: 0.02,
/** Incident (soif, poubelle, banc, animal loin, photo): base chance per visitor per tick when not in wait phase. */
IncidentChanceBase: 0.002,
/** Multiplier to incident chance when in wait phase (truck, sale pending, etc.). */
IncidentChanceWaitMultiplier: 4,
/** Seconds before unresolved incident: visitor leaves and attractivity penalty applied. */
IncidentTimeoutSeconds: 45,
/** Attractivity bonus when player resolves an incident. */
IncidentResolveAttractivityBonus: 0.15,
/** Coin bonus when player resolves an incident. */
IncidentResolveCoinBonus: 8,
/** Attractivity penalty when incident times out unresolved. */
IncidentUnresolvedAttractivityPenalty: 0.2,
},
Nursery: {
BuildCost: 200,
MaxLevel: 7,
BaseUpgradeCost: 180,
UpgradeGrowth: 1.5,
/** Seconds for a baby to become mature (divided by nursery level). */
GrowthSecondsBase: 40,
/** Seconds a mature baby can wait without being placed before dying. */
MaxSecondsMatureNotPlaced: 90,
},
/** Reproduction: delay between pair detection and baby birth; reduced by zoo score and biome/temp fit. */
Reproduction: {
/** Base seconds until baby is born for an eligible pair. */
BaseSeconds: 60,
/** Max Manhattan distance between blocks to count as adjacent (1 = edge-adjacent only). */
MaxDistance: 1,
},
SouvenirShop: {
BuildCost: 250,
MaxLevel: 7,
BaseUpgradeCost: 220,
UpgradeGrowth: 1.55,
},
Time: {
DayLengthSeconds: 120,
PhaseShift: 0,
},
Weather: {
ChangeIntervalSeconds: 45,
RainChance: 0.25,
CloudyChance: 0.35,
},
Quests: {
CountPerDay: 3,
RewardBase: 50,
RewardPerLevel: 20,
},
Prestige: {
IncomeBonusPerLevel: 0.15,
MinCoinsToReset: 5000,
},
/** Phase 10: sale listings (baby/animal on truck → world map). Bébé invendu meurt après ce délai. */
Sale: {
/** Seconds until a listing expires if not sold. After expiry, baby dies (deathCountRecent). */
ListingDurationSeconds: 3600,
/** Default asking price for a baby or animal put on sale. */
DefaultPrice: 50,
},
};

213
web/js/conveyor.js Normal file
View File

@@ -0,0 +1,213 @@
import { GameConfig } from "./config.js";
import { LootTables, getAnimalToEggTypeMap } from "./loot-tables.js";
import { defaultAnimalWeights } from "./state.js";
import { getZooSkillLevel } from "./bot-zoo.js";
import { getConveyorUpgradeCost, getSchoolUpgradeCost, getTruckUpgradeCost } from "./economy.js";
import { pickId } from "./weighted-random.js";
import { cellKey } from "./grid-utils.js";
const ANIMAL_TO_EGG_TYPE = getAnimalToEggTypeMap();
const DEFAULT_ZOO_WEIGHTS = defaultAnimalWeights();
/**
* Skill level = max level among school cells, or state.conveyorLevel for backward compat.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getSkillLevel(state) {
let maxSchool = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell.kind === "school") maxSchool = Math.max(maxSchool, cell.level);
}
return maxSchool || state.conveyorLevel || 1;
}
/**
* @param {number} conveyorLevel
* @returns {Array<{ id: string, weight: number }>}
*/
function getEligibleEggTypes(conveyorLevel) {
const entries = [];
for (const [eggType, def] of Object.entries(LootTables.EggTypes)) {
if (conveyorLevel >= def.minConveyorLevel)
entries.push({ id: eggType, weight: 100 - def.minConveyorLevel * 8 });
}
return entries;
}
/**
* Player zoo weights from grid: more animals of a type => more of that egg type at own zoo.
* @param {import("./types.js").GameState} state
* @returns {Record<string, number>}
*/
export function getPlayerZooWeights(state) {
const colorKeys = Object.keys(LootTables.EggTypes);
const w = Object.fromEntries(colorKeys.map((k) => [k, 0]));
for (const cell of Object.values(state.grid.cells)) {
if (cell.kind === "animal") {
const eggType = ANIMAL_TO_EGG_TYPE[cell.id];
if (eggType) w[eggType] = (w[eggType] ?? 0) + 1;
}
}
return w;
}
/**
* Zoos that can offer this egg type (skill level allows it and zoo has weight for it).
* @param {import("./types.js").GameState} state
* @param {string} eggType
* @returns {Array<{ id: string, weight: number }>}
*/
function getZoosForEggType(state, eggType) {
const zoos = state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: DEFAULT_ZOO_WEIGHTS }];
const eggDef = LootTables.EggTypes[eggType];
const minLevel = eggDef ? eggDef.minConveyorLevel : 1;
const playerWeights = getPlayerZooWeights(state);
const entries = [];
for (const zoo of zoos) {
const skillLevel = zoo.id === "player" ? getSkillLevel(state) : getZooSkillLevel(zoo);
if (skillLevel >= minLevel) {
const weights = zoo.id === "player" ? playerWeights : (zoo.animalWeights ?? {});
const w = weights[eggType] ?? 0;
if (w > 0) entries.push({ id: zoo.id, weight: w });
}
}
if (entries.length === 0) entries.push({ id: "player", weight: 1 });
return entries;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function refreshOffers(state, nowUnix) {
const rng = () => Math.random();
const skillLevel = getSkillLevel(state);
const pool = getEligibleEggTypes(skillLevel);
const offers = [];
for (let i = 0; i < GameConfig.Conveyor.OfferCount; i++) {
const eggType = pickId(rng, pool);
const eggDef = LootTables.EggTypes[eggType];
const zooPool = getZoosForEggType(state, eggType);
const zooId = zooPool.length ? pickId(rng, zooPool) : "player";
offers.push({ eggType, price: eggDef.price, zooId });
}
const animalIds = Object.keys(LootTables.Animals ?? {});
if (animalIds.length > 0) {
const babyAnimalId = animalIds[Math.floor(rng() * animalIds.length)];
const babyDef = LootTables.Animals[babyAnimalId];
const babyPrice = babyDef ? Math.floor(50 + (babyDef.rarityLevel ?? 1) * 30) : 80;
offers.push({ type: "baby", animalId: babyAnimalId, price: babyPrice, zooId: "player" });
const adultAnimalId = animalIds[Math.floor(rng() * animalIds.length)];
const adultDef = LootTables.Animals[adultAnimalId];
const adultPrice = adultDef ? Math.floor(80 + (adultDef.rarityLevel ?? 1) * 40) : 120;
offers.push({ type: "animal", animalId: adultAnimalId, price: adultPrice, zooId: "player" });
}
state.conveyorOffers = offers;
state.lastOfferRefreshAt = nowUnix;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {boolean}
*/
export function shouldRefresh(state, nowUnix) {
return nowUnix - state.lastOfferRefreshAt >= GameConfig.Conveyor.RefreshSeconds;
}
/**
* Upgrade school at cell (x,y). Returns [ok, reason].
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, string?]}
*/
export function tryUpgradeSchool(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "school") return [false, "NoSchool"];
const maxLevel = (GameConfig.School && GameConfig.School.MaxLevel) || GameConfig.Conveyor.MaxLevel;
if (cell.level >= maxLevel) return [false, "ConveyorMaxLevel"];
const cost = getSchoolUpgradeCost(cell.level);
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
cell.level += 1;
state.conveyorLevel = getSkillLevel(state);
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
if (state.stats) state.stats.conveyorUpgrades = (state.stats.conveyorUpgrades ?? 0) + 1;
return [true, undefined];
}
/**
* Upgrade truck. Returns [ok, reason].
* @param {import("./types.js").GameState} state
* @returns {[boolean, string?]}
*/
export function tryUpgradeTruck(state) {
const level = state.truckLevel ?? 1;
const maxLevel = (GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5;
if (level >= maxLevel) return [false, "TruckMaxLevel"];
const cost = getTruckUpgradeCost(level);
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
state.truckLevel = level + 1;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
if (state.stats) state.stats.truckUpgrades = (state.stats.truckUpgrades ?? 0) + 1;
return [true, undefined];
}
/**
* @param {import("./types.js").GameState} state
* @returns {[boolean, string?]}
*/
export function tryUpgrade(state) {
if (state.conveyorLevel >= GameConfig.Conveyor.MaxLevel) return [false, "ConveyorMaxLevel"];
const cost = getConveyorUpgradeCost(state.conveyorLevel);
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
state.conveyorLevel += 1;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
if (state.stats) state.stats.conveyorUpgrades = (state.stats.conveyorUpgrades ?? 0) + 1;
return [true, undefined];
}
/**
* @param {import("./types.js").GameState} state
* @param {string} eggType
* @returns {{ eggType: string, price: number, zooId?: string } | null}
*/
export function findOffer(state, eggType) {
return state.conveyorOffers.find((o) => o.eggType === eggType) ?? null;
}
/**
* @param {import("./types.js").GameState} state
* @param {string} [animalId]
* @returns {{ type: "baby", animalId: string, price: number, zooId?: string } | null}
*/
export function findBabyOffer(state, animalId) {
const o = state.conveyorOffers.find((x) => x.type === "baby" && (animalId === null || animalId === undefined || x.animalId === animalId));
return o && o.type === "baby" ? o : null;
}
/**
* @param {import("./types.js").GameState} state
* @param {string} [animalId]
* @returns {{ type: "animal", animalId: string, price: number, zooId?: string } | null}
*/
export function findAnimalOffer(state, animalId) {
const o = state.conveyorOffers.find((x) => x.type === "animal" && (animalId === null || animalId === undefined || x.animalId === animalId));
return o && o.type === "animal" ? o : null;
}
/**
* Pick another zoo (not player) for truck sale animation.
* @param {import("./types.js").GameState} state
* @returns {string}
*/
export function pickSaleTargetZoo(state) {
const zoos = (state.worldZoos ?? []).filter((z) => z.id !== "player");
if (zoos.length === 0) return "player";
return zoos[Math.floor(Math.random() * zoos.length)].id;
}

View File

@@ -0,0 +1,66 @@
/**
* Shared default zoo grid layout: row 1 building cells and 3 starter couples (row 2).
* Used by state.js (defaultState) and prestige.js (doPrestige).
*/
import { LootTables } from "./loot-tables.js";
import { fillAnimalBlock } from "./placement.js";
/** Row 1 layout: (col, kind). Columns 16. */
const ROW1_LAYOUT = [
[1, "research"],
[2, "billeterie"],
[3, "nursery"],
[4, "reception"],
[5, "food"],
[6, "school"],
];
/**
* Build default cells for row 1 (research, billeterie, nursery, reception, food, school).
* @returns {Record<string, { kind: string, level: number }>}
*/
export function buildDefaultRow1Cells() {
const cells = {};
for (const [col, kind] of ROW1_LAYOUT) {
cells[`${col}_1`] = { kind, level: 1 };
}
return cells;
}
/** Default animal ids for the 3 starter couples (one per biome: Meadow, Ocean, Mountain). */
export const STARTER_ANIMAL_IDS_BY_BIOME = ["c0_r0", "c5_r0", "c10_r0"];
/** Positions for the 6 starter animals (row 2, columns 16). */
export const STARTER_ANIMAL_POSITIONS = [[1, 2], [2, 2], [3, 2], [4, 2], [5, 2], [6, 2]];
/**
* Place 3 breeding couples (6 animals) on the grid. Mutates state.grid.cells.
* @param {import("./types.js").GameState} state
*/
export function addStarterAnimals(state) {
const now = Math.floor(Date.now() / 1000);
let idx = 0;
for (const animalId of STARTER_ANIMAL_IDS_BY_BIOME) {
const def = LootTables.Animals[animalId];
if (def !== null && def !== undefined) {
const w = def.cellsWide ?? 1;
const h = def.cellsHigh ?? 1;
for (let pair = 0; pair < 2 && idx < STARTER_ANIMAL_POSITIONS.length; pair++, idx++) {
const [x, y] = STARTER_ANIMAL_POSITIONS[idx];
fillAnimalBlock(state, x, y, {
kind: "animal",
id: animalId,
mutation: "none",
level: 1,
placedAt: now,
lastVisitedAt: now,
lastFedAt: now,
cellsWide: w,
cellsHigh: h,
fromOtherZoo: false,
});
}
}
}
}

306
web/js/economy.js Normal file
View File

@@ -0,0 +1,306 @@
import { GameConfig } from "./config.js";
/**
* @param {number} baseCost
* @param {number} growth
* @param {number} level
* @returns {number}
*/
export function exponentialCost(baseCost, growth, level) {
const exponent = Math.max(0, level - 1);
return Math.floor(baseCost * Math.pow(growth, exponent) + 0.5);
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getConveyorUpgradeCost(currentLevel) {
return exponentialCost(
GameConfig.Conveyor.BaseUpgradeCost,
GameConfig.Conveyor.UpgradeGrowth,
currentLevel
);
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getTruckUpgradeCost(currentLevel) {
return exponentialCost(
GameConfig.Truck.BaseUpgradeCost,
GameConfig.Truck.UpgradeGrowth,
currentLevel
);
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getSchoolUpgradeCost(currentLevel) {
return exponentialCost(
GameConfig.School.BaseUpgradeCost,
GameConfig.School.UpgradeGrowth,
currentLevel
);
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getWorldMapUpgradeCost(currentLevel) {
const cfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* Research points required to upgrade world map level (phase 9: agrandissement en unités de recherche).
* @param {number} currentLevel
* @returns {number}
*/
export function getWorldMapUpgradeResearchCost(currentLevel) {
const cfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade;
if (!cfg || cfg.BaseResearchCost === null || cfg.BaseResearchCost === undefined) return 9999;
const growth = cfg.ResearchUpgradeGrowth ?? 1.6;
return exponentialCost(cfg.BaseResearchCost, growth, currentLevel);
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getPlotUpgradeCost(currentLevel) {
return exponentialCost(
GameConfig.Plot.BaseUpgradeCost,
GameConfig.Plot.UpgradeGrowth,
currentLevel
);
}
/**
* @returns {number}
*/
export function getNurseryBuildCost() {
return GameConfig.Nursery?.BuildCost ?? 200;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getNurseryUpgradeCost(currentLevel) {
const cfg = GameConfig.Nursery;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* @returns {number}
*/
export function getSouvenirShopBuildCost() {
return GameConfig.SouvenirShop?.BuildCost ?? 250;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getSouvenirShopUpgradeCost(currentLevel) {
const cfg = GameConfig.SouvenirShop;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* @returns {number}
*/
export function getResearchBuildCost() {
return GameConfig.Research?.BuildCost ?? 300;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getResearchUpgradeCost(currentLevel) {
const cfg = GameConfig.Research;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* @returns {number}
*/
export function getBilleterieBuildCost() {
return GameConfig.Billeterie?.BuildCost ?? 280;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getBilleterieUpgradeCost(currentLevel) {
const cfg = GameConfig.Billeterie;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* @returns {number}
*/
export function getFoodBuildCost() {
return GameConfig.Food?.BuildCost ?? 260;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getFoodUpgradeCost(currentLevel) {
const cfg = GameConfig.Food;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* @returns {number}
*/
export function getReceptionBuildCost() {
return GameConfig.Reception?.BuildCost ?? 240;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getReceptionUpgradeCost(currentLevel) {
const cfg = GameConfig.Reception;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* @returns {number}
*/
export function getBiomeChangeColorBuildCost() {
return GameConfig.BiomeChangeColor?.BuildCost ?? 340;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getBiomeChangeColorUpgradeCost(currentLevel) {
const cfg = GameConfig.BiomeChangeColor;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/**
* @returns {number}
*/
export function getBiomeChangeTempBuildCost() {
return GameConfig.BiomeChangeTemp?.BuildCost ?? 340;
}
/**
* @param {number} currentLevel
* @returns {number}
*/
export function getBiomeChangeTempUpgradeCost(currentLevel) {
const cfg = GameConfig.BiomeChangeTemp;
if (!cfg) return 9999;
return exponentialCost(cfg.BaseUpgradeCost, cfg.UpgradeGrowth, currentLevel);
}
/** Building kinds that use the same build/upgrade pattern (7 levels). */
export const BUILDING_KINDS = [
"research",
"billeterie",
"food",
"reception",
"biomeChangeColor",
"biomeChangeTemp",
];
/**
* @param {typeof BUILDING_KINDS[number]} kind
* @returns {number}
*/
export function getBuildingBuildCost(kind) {
switch (kind) {
case "research":
return getResearchBuildCost();
case "billeterie":
return getBilleterieBuildCost();
case "food":
return getFoodBuildCost();
case "reception":
return getReceptionBuildCost();
case "biomeChangeColor":
return getBiomeChangeColorBuildCost();
case "biomeChangeTemp":
return getBiomeChangeTempBuildCost();
default:
return 9999;
}
}
/**
* @param {typeof BUILDING_KINDS[number]} kind
* @param {number} currentLevel
* @returns {number}
*/
export function getBuildingUpgradeCost(kind, currentLevel) {
switch (kind) {
case "research":
return getResearchUpgradeCost(currentLevel);
case "billeterie":
return getBilleterieUpgradeCost(currentLevel);
case "food":
return getFoodUpgradeCost(currentLevel);
case "reception":
return getReceptionUpgradeCost(currentLevel);
case "biomeChangeColor":
return getBiomeChangeColorUpgradeCost(currentLevel);
case "biomeChangeTemp":
return getBiomeChangeTempUpgradeCost(currentLevel);
default:
return 9999;
}
}
/**
* @param {typeof BUILDING_KINDS[number]} kind
* @returns {number}
*/
export function getBuildingMaxLevel(kind) {
const key = kind.charAt(0).toUpperCase() + kind.slice(1);
const cfg = GameConfig[key];
return cfg?.MaxLevel ?? 7;
}
/**
* @param {number} level
* @returns {number}
*/
export function getLevelMultiplier(level) {
const current = level ?? 1;
return 1 + 0.1 * Math.max(0, current - 1);
}
/**
* @param {number} baseIncomePerSecond
* @param {number} level
* @param {number} mutationMultiplier
* @param {number} sellFactor
* @returns {number}
*/
export function getSellValue(baseIncomePerSecond, level, mutationMultiplier, sellFactor) {
const levelMult = getLevelMultiplier(level);
return Math.floor(baseIncomePerSecond * mutationMultiplier * levelMult * sellFactor + 0.5);
}

7
web/js/event-service.js Normal file
View File

@@ -0,0 +1,7 @@
/**
* @param {number} _nowUnix
* @returns {{ incomeMultiplier: number, mutationBonus: number }}
*/
export function getActiveModifiers(_nowUnix) {
return { incomeMultiplier: 1, mutationBonus: 0 };
}

213
web/js/food.js Normal file
View File

@@ -0,0 +1,213 @@
/**
* Food capacity and feeding tick. Animals are fed up to capacity each tick;
* unfed animals accumulate time without food and are removed by checkDeathCauses.
*/
import { GameConfig } from "./config.js";
import { LootTables } from "./loot-tables.js";
import { isOriginCell } from "./grid-utils.js";
import { getBlockKeysFromCell } from "./placement.js";
import { getDisplayBiome, getDisplayTemperature, isAnimalAllowedOnBiome } from "./biome-rules.js";
/**
* Total food capacity = sum over food cells of (level × AnimalsPerUnit).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getFoodCapacity(state) {
const cfg = GameConfig.Food;
if (!cfg) return 0;
const unit = cfg.AnimalsPerUnit ?? 5;
let total = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "food") {
total += (cell.level ?? 1) * unit;
}
}
return total;
}
/**
* Count origin animal cells (each animal block counts once).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getOriginAnimalCount(state) {
let n = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) n += 1;
}
return n;
}
/**
* Feed up to `capacity` animals this tick. Animals with oldest lastFedAt are fed first.
* Sets lastFedAt = nowUnix on each fed animal (all cells of the block).
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function tickFeeding(state, nowUnix) {
const capacity = getFoodCapacity(state);
if (capacity <= 0) return;
const originAnimals = [];
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) {
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
originAnimals.push({ key, cell, lastFed });
}
}
originAnimals.sort((a, b) => a.lastFed - b.lastFed);
let fed = 0;
for (const { key, cell } of originAnimals) {
if (fed >= capacity) break;
const m = key.match(/^(\d+)_(\d+)$/);
if (m) {
setBlockLastFedAt(state, {
ox: Number(m[1]),
oy: Number(m[2]),
w: cell.cellsWide ?? 1,
h: cell.cellsHigh ?? 1,
nowUnix,
});
fed += 1;
}
}
}
function setBlockLastFedAt(state, opts) {
const { ox, oy, w, h, nowUnix } = opts;
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
const k = `${ox + dx}_${oy + dy}`;
const c = state.grid.cells[k];
if (c && c.kind === "animal") c.lastFedAt = nowUnix;
}
}
}
/**
* Compute feeding rate = ratio of animals that were fed this period (instantaneous:
* fed count / total origin count). Call after tickFeeding; store in state.feedingRate for display.
* @param {import("./types.js").GameState} state
* @param {number} _nowUnix
* @returns {number} 0..1
*/
export function getFeedingRate(state, _nowUnix) {
const total = getOriginAnimalCount(state);
if (total <= 0) return 1;
const capacity = getFoodCapacity(state);
const fed = Math.min(total, capacity);
return fed / total;
}
/**
* Remove animals and entities that meet death conditions. Increments state.deathCountRecent.
* Causes: not visited, not fed, temperature out of range, biome not allowed,
* baby mature not placed in time, reception animal ready not placed in time.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function checkDeathCauses(state, nowUnix) {
const maxVisit = GameConfig.Visitor?.MaxSecondsWithoutVisit ?? 300;
const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120;
const maxMatureNotPlaced = GameConfig.Nursery?.MaxSecondsMatureNotPlaced ?? 90;
const maxReadyNotPlaced = GameConfig.Reception?.MaxSecondsReadyNotPlaced ?? 90;
const grid = state.grid;
const cells = grid.cells;
const blocksToRemove = collectAnimalDeathBlocks({ state, grid, cells, nowUnix, maxVisit, maxFood });
for (const { ox, oy } of blocksToRemove) {
const blockKeys = getBlockKeysFromCell(state, ox, oy);
for (const k of blockKeys) delete cells[k];
state.deathCountRecent = (state.deathCountRecent ?? 0) + 1;
}
const babiesRemoved = filterPendingBabies(state, nowUnix, maxMatureNotPlaced);
if (babiesRemoved > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babiesRemoved;
const receptionRemoved = filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced);
if (receptionRemoved > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + receptionRemoved;
}
/**
* @param {{ state: import("./types.js").GameState, grid: { width: number, height: number }, cells: Record<string, import("./types.js").Cell>, nowUnix: number, maxVisit: number, maxFood: number }} opts
* @returns {Array<{ ox: number, oy: number }>}
*/
function collectAnimalDeathBlocks(opts) {
const { grid, cells, nowUnix, maxVisit, maxFood } = opts;
const blocksToRemove = [];
for (const [key, cell] of Object.entries(cells)) {
if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip
} else {
const def = LootTables.Animals[cell.id];
if (def !== null && def !== undefined) {
const entry = maybeDeathBlock({ key, cell, grid, nowUnix, maxVisit, maxFood, def });
if (entry) blocksToRemove.push(entry);
}
}
}
return blocksToRemove;
}
function maybeDeathBlock(opts) {
const { key, cell, grid, nowUnix, maxVisit, maxFood, def } = opts;
const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix;
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
const m = key.match(/^(\d+)_(\d+)$/);
if (!m) return null;
const ox = Number(m[1]);
const oy = Number(m[2]);
const cellBiome = getDisplayBiome(ox, oy, grid);
const cellTemp = getDisplayTemperature(ox, oy, grid);
const idealTemp = def.idealTemperature ?? 18;
const tolerance = def.temperatureTolerance ?? 5;
const tempOk = Math.abs(cellTemp - idealTemp) <= tolerance;
const biomeOk = isAnimalAllowedOnBiome(def.biome, cellBiome);
const visitedOk = nowUnix - lastVisited < maxVisit;
const fedOk = nowUnix - lastFed < maxFood;
if (!visitedOk || !fedOk || !tempOk || !biomeOk) return { ox, oy };
return null;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @param {number} maxMatureNotPlaced
* @returns {number}
*/
function filterPendingBabies(state, nowUnix, maxMatureNotPlaced) {
const pendingBabies = state.pendingBabies ?? [];
let removed = 0;
state.pendingBabies = pendingBabies.filter((p) => {
if (nowUnix <= p.readyAt) return true;
if (nowUnix - p.readyAt >= maxMatureNotPlaced) {
removed += 1;
return false;
}
return true;
});
return removed;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @param {number} maxReadyNotPlaced
* @returns {number}
*/
function filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced) {
const receptionAnimals = state.receptionAnimals ?? [];
let removed = 0;
state.receptionAnimals = receptionAnimals.filter((r) => {
if (nowUnix <= r.readyAt) return true;
if (nowUnix - r.readyAt >= maxReadyNotPlaced) {
removed += 1;
return false;
}
return true;
});
return removed;
}

111
web/js/game-loop.js Normal file
View File

@@ -0,0 +1,111 @@
import { GameConfig } from "./config.js";
import { refreshOffers, shouldRefresh } from "./conveyor.js";
import { run as runHatching } from "./hatching.js";
import { tick as incomeTick, tickVisitorArrivals, getAttractivityScore } from "./income.js";
import { getActiveModifiers } from "./event-service.js";
import { tickTime, tickWeather } from "./time-weather.js";
import { tickQuests } from "./quests.js";
import { saveState } from "./state.js";
import { playSound } from "./audio.js";
import { pruneTruckSales, addNpcTruckSale, shouldAddNpcTruck, tickLaboratory } from "./world-map.js";
import { tickAnimalVisits } from "./animal-visits.js";
import { tickPlayerAutoMode } from "./bot-zoo.js";
import { tickFeeding, checkDeathCauses, getFeedingRate } from "./food.js";
import { tickReproduction, getReproductionScore } from "./reproduction.js";
import { tickVisitorIncidents } from "./visitor-incidents.js";
import { tickSaleListings } from "./trade.js";
/**
* Add research points from all research cells. PointsPerTickPerLevel * level per second.
* @param {import("./types.js").GameState} state
* @param {number} dt
* @returns {void}
*/
function tickResearch(state, dt) {
const cfg = GameConfig.Research;
if (!cfg || cfg.PointsPerTickPerLevel === null || cfg.PointsPerTickPerLevel === undefined) return;
const pointsPerLevelPerSecond = cfg.PointsPerTickPerLevel;
let total = 0;
for (const [, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "research") {
const level = cell.level ?? 1;
total += pointsPerLevelPerSecond * level * dt;
}
}
if (total > 0) {
state.researchPoints = (state.researchPoints ?? 0) + total;
}
}
/**
* Run one simulation tick: time, feeding, reproduction, visitors, income, research, hatching, quests.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @param {number} nowMs
* @param {number} dt
* @returns {{ hatched: Array<{ x: number, y: number }>, questEarned: number }}
*/
function doOneTick(state, nowUnix, nowMs, dt) {
if (shouldRefresh(state, nowUnix)) refreshOffers(state, nowUnix);
pruneTruckSales(state, nowMs);
const eventModifiers = getActiveModifiers(nowUnix);
tickTime(state, dt);
tickWeather(state, nowUnix);
tickAnimalVisits(state, nowUnix, nowMs);
if (state.autoMode) tickPlayerAutoMode(state, nowUnix);
tickFeeding(state, nowUnix);
state.feedingRate = getFeedingRate(state, nowUnix);
checkDeathCauses(state, nowUnix);
tickReproduction(state, nowUnix);
state.reproductionScore = getReproductionScore(state);
tickSaleListings(state, nowUnix);
tickVisitorArrivals(state, nowUnix);
tickVisitorIncidents(state, nowUnix);
incomeTick(state, dt, eventModifiers);
tickResearch(state, dt);
state.attractivityScore = getAttractivityScore(state);
const { hatched } = runHatching(state, nowUnix, eventModifiers);
const questEarned = tickQuests(state);
return { hatched, questEarned };
}
/**
* @param {() => import("./types.js").GameState} getState
* @param {(state: import("./types.js").GameState, payload: { lastHatched?: Array<{ x: number, y: number }> }) => void} onUpdate
* @param {(state: import("./types.js").GameState) => void} [saveStateFn]
* @returns {void}
*/
export function startGameLoop(getState, onUpdate, saveStateFn) {
const save = saveStateFn || saveState;
let lastWall = performance.now() / 1000;
let saveAccum = 0;
let lastNpcTruckAt = 0;
function loop() {
const state = getState();
const nowWall = performance.now() / 1000;
const dt = Math.min(nowWall - lastWall, 2);
lastWall = nowWall;
const nowUnix = Math.floor(Date.now() / 1000);
const nowMs = Date.now();
if (shouldAddNpcTruck(nowMs, lastNpcTruckAt)) {
addNpcTruckSale(state, nowMs);
lastNpcTruckAt = nowMs;
}
tickLaboratory(state, nowUnix);
const { hatched, questEarned } = doOneTick(state, nowUnix, nowMs, dt);
if (questEarned > 0) playSound("quest");
onUpdate(state, { lastHatched: hatched });
saveAccum += dt;
if (saveAccum >= GameConfig.SaveIntervalMs / 1000) {
saveAccum = 0;
save(state);
}
}
const intervalMs = Math.max(100, GameConfig.IncomeTickMs);
setInterval(loop, intervalMs);
loop();
}

62
web/js/grid-utils.js Normal file
View File

@@ -0,0 +1,62 @@
import { GameConfig } from "./config.js";
/**
* @param {number} x
* @param {number} y
* @returns {string}
*/
export function cellKey(x, y) {
return `${x}_${y}`;
}
/**
* @param {number} plotLevel
* @returns {[number, number]}
*/
export function plotSizeFromLevel(plotLevel) {
const level = Math.max(1, Math.min(GameConfig.Plot.MaxLevel, plotLevel));
const extra = (level - 1) * GameConfig.Plot.ExpandByLevel;
return [
GameConfig.Plot.BaseWidth + extra,
GameConfig.Plot.BaseHeight + extra,
];
}
/**
* @param {number} width
* @param {number} height
* @param {number} x
* @param {number} y
* @returns {boolean}
*/
export function withinBounds(width, height, x, y) {
return x >= 1 && y >= 1 && x <= width && y <= height;
}
/**
* Keys for a rectangular block (origin = top-left). All coordinates 1-based.
* @param {number} originX
* @param {number} originY
* @param {number} w
* @param {number} h
* @returns {string[]}
*/
export function getBlockKeys(originX, originY, w, h) {
const keys = [];
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
keys.push(cellKey(originX + dx, originY + dy));
}
}
return keys;
}
/**
* @param {string} key "x_y"
* @param {import("./types.js").Cell} cell
* @returns {boolean}
*/
export function isOriginCell(key, cell) {
if (cell === null || cell === undefined || cell.kind !== "animal") return false;
return cell.originKey === null || cell.originKey === undefined || cell.originKey === key;
}

112
web/js/hatching.js Normal file
View File

@@ -0,0 +1,112 @@
import { GameConfig } from "./config.js";
import { LootTables } from "./loot-tables.js";
import { getMutationEntries, getIncomeMultiplier } from "./mutation-rules.js";
import { getCellBiome, getBiomesCompatibleWithCell } from "./biome-rules.js";
import { cellKey } from "./grid-utils.js";
import { fillAnimalBlock, canPlaceMultiCell } from "./placement.js";
import { createSeededRng, pickId } from "./weighted-random.js";
const BIOME_TO_EGG_TYPE = { Meadow: "Color_1", Ocean: "Color_6", Mountain: "Color_11", Forest: "Color_1", Freshwater: "Color_6" };
/**
* Loot entries for animals that match the cell biome. If the egg type has none, use the egg type for that biome.
* @param {string} cellBiome
* @param {Array<{ id: string, weight: number }>} loot
* @returns {Array<{ id: string, weight: number }>}
*/
function lootForBiome(cellBiome, loot) {
const allowedBiomes = getBiomesCompatibleWithCell(cellBiome);
const allowed = loot.filter((entry) => {
const def = LootTables.Animals[entry.id];
return def && allowedBiomes.includes(def.biome);
});
if (allowed.length > 0) return allowed;
const eggType = BIOME_TO_EGG_TYPE[cellBiome];
const fallbackDef = eggType ? LootTables.EggTypes[eggType] : null;
return fallbackDef ? fallbackDef.loot : loot;
}
/**
* @param {string} animalId
* @param {string} mutationId
* @param {number} nowUnix
* @param {{ cellsWide?: number, cellsHigh?: number }} dimensions
* @returns {import("./types.js").AnimalCell}
*/
function buildAnimalCell(animalId, mutationId, nowUnix, dimensions = {}) {
return {
kind: "animal",
id: animalId,
mutation: mutationId,
level: 1,
placedAt: nowUnix,
lastVisitedAt: nowUnix,
lastFedAt: nowUnix,
...dimensions,
};
}
/**
* @param {import("./types.js").GameState} state
* @param {{ x: number, y: number, nowUnix: number, eventModifiers: { incomeMultiplier: number, mutationBonus: number } }} opts
* @returns {boolean}
*/
export function tryHatchCell(state, opts) {
const { x, y, nowUnix, eventModifiers } = opts;
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "egg") return false;
if (nowUnix < cell.hatchAt) return false;
const eggDef = LootTables.EggTypes[cell.eggType];
if (eggDef === null || eggDef === undefined) throw new Error("HatchingService: unknown egg type");
const cellBiome = getCellBiome(state.grid.width, state.grid.height, x, y);
const loot = lootForBiome(cellBiome, eggDef.loot);
if (loot.length === 0) return false;
const rng = createSeededRng(cell.seed);
const pickedAnimalId = pickId(rng, loot);
const animalDef = LootTables.Animals[pickedAnimalId];
if (animalDef === null || animalDef === undefined) return false;
const mutationChance = GameConfig.Mutation.BaseChance + eventModifiers.mutationBonus;
let mutationId = "none";
if (rng() < mutationChance) mutationId = pickId(rng, getMutationEntries());
if (getIncomeMultiplier(mutationId) === undefined) mutationId = "none";
const w = animalDef.cellsWide ?? 1;
const h = animalDef.cellsHigh ?? 1;
const [canPlace, _reason] = canPlaceMultiCell(state, { originX: x, originY: y, w, h, excludeOriginKey: key });
if (!canPlace) return false;
const animalData = buildAnimalCell(pickedAnimalId, mutationId, nowUnix, {
cellsWide: w,
cellsHigh: h,
});
fillAnimalBlock(state, x, y, animalData);
return true;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @param {{ incomeMultiplier: number, mutationBonus: number }} eventModifiers
* @returns {{ changed: boolean, hatched: Array<{ x: number, y: number }> }}
*/
export function run(state, nowUnix, eventModifiers) {
const hatched = [];
const keysToProcess = [];
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "egg" && nowUnix >= cell.hatchAt) keysToProcess.push(key);
}
for (const key of keysToProcess) {
const m = key.match(/^(\d+)_(\d+)$/);
if (m) {
const x = Number(m[1]);
const y = Number(m[2]);
const didHatch = tryHatchCell(state, { x, y, nowUnix, eventModifiers });
if (didHatch && state.grid.cells[cellKey(x, y)]?.kind === "animal") hatched.push({ x, y });
}
}
return { changed: hatched.length > 0, hatched };
}

288
web/js/income.js Normal file
View File

@@ -0,0 +1,288 @@
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 };
}

117
web/js/loot-tables.js Normal file
View File

@@ -0,0 +1,117 @@
/**
* 15 color nuances, 5 rarity levels per color = 75 animals.
* Rarity levels scaled by Fibonacci: F(1)=1, F(2)=1, F(3)=2, F(4)=3, F(5)=5.
*/
const NUM_COLORS = 15;
const NUM_RARITY_LEVELS = 5;
const FIBONACCI = [0, 1, 1, 2, 3, 5];
export function getFibonacciRarity(rarityLevel) {
const n = Math.max(1, Math.min(NUM_RARITY_LEVELS, Math.floor(rarityLevel)));
return FIBONACCI[n] ?? 1;
}
const BIOMES = ["Meadow", "Ocean", "Mountain"];
function getBiomeForColorIndex(colorIndex) {
return BIOMES[Math.floor((colorIndex % NUM_COLORS) / 5)] ?? "Meadow";
}
function buildAnimalId(colorIndex, rarityIndex) {
return `c${colorIndex}_r${rarityIndex}`;
}
const EggTypes = {};
const Animals = {};
for (let c = 0; c < NUM_COLORS; c++) {
const eggTypeKey = `Color_${c + 1}`;
const loot = [];
for (let r = 0; r < NUM_RARITY_LEVELS; r++) {
const animalId = buildAnimalId(c, r);
const rarityLevel = r + 1;
const fib = getFibonacciRarity(rarityLevel);
const biome = getBiomeForColorIndex(c);
const reproductionScoreByBiome = {};
const survivalScoreByBiome = {};
for (const b of BIOMES) {
reproductionScoreByBiome[b] = b === biome ? 1 : 0.5;
survivalScoreByBiome[b] = b === biome ? 1 : 0.6;
}
Animals[animalId] = {
baseIncomePerSecond: 0.4 * fib,
rarity: String(rarityLevel),
biome,
sellFactor: 12 + fib * 6,
rarityLevel,
cellsWide: (c === 7 && r === 1) ? 2 : 1,
cellsHigh: (c === 7 && r === 1) ? 2 : 1,
idealTemperature: 18 + (c % 5),
temperatureTolerance: 5,
reproductionScoreByBiome,
survivalScoreByBiome,
};
const weight = 60 - r * 12;
loot.push({ id: animalId, weight: Math.max(10, weight) });
}
EggTypes[eggTypeKey] = {
price: 35 + c * 12 + (c % 5) * 5,
hatchSeconds: 18 + c * 2,
minConveyorLevel: 1 + Math.floor(c / 5),
loot,
};
}
export const LootTables = {
EggTypes,
Animals,
};
export function getAnimalToEggTypeMap() {
const map = {};
for (let c = 0; c < NUM_COLORS; c++) {
const eggTypeKey = `Color_${c + 1}`;
for (let r = 0; r < NUM_RARITY_LEVELS; r++) {
map[buildAnimalId(c, r)] = eggTypeKey;
}
}
return map;
}
export function getColorNames() {
const names = [];
for (let i = 0; i < NUM_COLORS; i++) {
names.push(`Color_${i + 1}`);
}
return names;
}
/** All color keys with value 0. Use for initial weights or aggregation.
* @returns {Record<string, number>}
*/
export function zeroAnimalWeights() {
return Object.fromEntries(getColorNames().map((c) => [c, 0]));
}
export function getRarityLevelFromAnimalId(animalId) {
const def = Animals[animalId];
return def?.rarityLevel ?? 1;
}
/**
* Hatch time multiplier from egg type rarity (rarer eggs take longer). Used when placing egg from nursery.
* @param {string} eggType
* @returns {number}
*/
export function getRarityHatchMultiplierForEggType(eggType) {
const def = EggTypes[eggType];
if (!def || !def.loot || def.loot.length === 0) return 1;
let maxRarity = 1;
for (const entry of def.loot) {
const r = getRarityLevelFromAnimalId(entry.id);
if (r > maxRarity) maxRarity = r;
}
return 1 + (maxRarity - 1) * 0.2;
}

178
web/js/main-bootstrap.js Normal file
View File

@@ -0,0 +1,178 @@
/**
* Bootstrap helpers: load state from API or show register/connect UI.
*/
import { defaultState, normalizeZooWeights } from "./state.js";
import { refreshOffers, getPlayerZooWeights } from "./conveyor.js";
import { ensureBotState } from "./bot-zoo.js";
import { loadZoos, loadMyZoo, register, createMyZoo } from "./api-client.js";
import { getOrCreateKeyPair } from "./auth-client.js";
/**
* @param {import("./types.js").GameState} gameState
* @param {{ zoosData: { worldZoos?: Array<unknown> }, playerZooId?: string, playerName?: string, playerX?: number, playerY?: number }} opts
* @returns {void}
*/
export function applyWorldZoos(gameState, opts) {
const { zoosData, playerZooId, playerName, playerX, playerY } = opts;
const playerWeights = getPlayerZooWeights(gameState);
const playerEntry = {
id: "player",
name: playerName || "Mon zoo",
x: playerX ?? 25,
y: playerY ?? 50,
animalWeights: playerWeights,
};
const others = (zoosData.worldZoos || [])
.filter((z) => z.id !== playerZooId)
.map((z) => ({
...z,
animalWeights: normalizeZooWeights(z.animalWeights),
botState: z.game_state ?? undefined,
}));
others.forEach((zoo) => ensureBotState(zoo, false));
gameState.worldZoos = [playerEntry, ...others];
}
/**
* @returns {Promise<import("./types.js").GameState>}
*/
export function bootstrapNoKeys() {
const s = defaultState();
const nowUnix = Math.floor(Date.now() / 1000);
refreshOffers(s, nowUnix);
return Promise.resolve(s);
}
/**
* @param {{ worldZoos?: Array<unknown> }} zoosData
* @param {HTMLElement} rootEl
* @param {(zooId: string) => void} setMyZooId
* @returns {Promise<import("./types.js").GameState>}
*/
export function bootstrapShowRegisterPanel(zoosData, rootEl, setMyZooId) {
return new Promise((resolve) => {
rootEl.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1><p>Créer un compte (pseudo, pas de mot de passe)</p><input id=\"boot-pseudo\" type=\"text\" placeholder=\"Pseudo\" maxlength=\"32\" /><button id=\"boot-submit\">Créer</button><p id=\"boot-err\" class=\"boot-err\"></p></div>";
const errEl = document.getElementById("boot-err");
document.getElementById("boot-submit").addEventListener("click", async () => {
const pseudo = document.getElementById("boot-pseudo").value.trim();
if (pseudo.length < 2) {
errEl.textContent = "Pseudo min. 2 caractères";
return;
}
errEl.textContent = "";
try {
await register(pseudo);
const s = defaultState();
const nowUnix = Math.floor(Date.now() / 1000);
refreshOffers(s, nowUnix);
const created = await createMyZoo(pseudo, s);
setMyZooId(created.zooId);
applyWorldZoos(s, { zoosData, playerZooId: created.zooId, playerName: created.name, playerX: created.x, playerY: created.y });
s.myZooId = created.zooId;
s.playerName = created.name;
s.playerX = created.x;
s.playerY = created.y;
resolve(s);
} catch (e) {
errEl.textContent = e.message || "Erreur";
}
});
});
}
/**
* @param {{ worldZoos?: Array<unknown> }} zoosData
* @returns {Promise<import("./types.js").GameState>}
*/
export async function bootstrapMe404(zoosData) {
const s = defaultState();
const nowUnix = Math.floor(Date.now() / 1000);
refreshOffers(s, nowUnix);
const created = await createMyZoo("Mon zoo", s);
const myZooId = created.zooId;
applyWorldZoos(s, { zoosData, playerZooId: myZooId, playerName: created.name, playerX: created.x, playerY: created.y });
s.myZooId = myZooId;
s.playerName = created.name;
s.playerX = created.x;
s.playerY = created.y;
return s;
}
/**
* @param {{ game_state: import("./types.js").GameState; zooId: string; name: string; x: number; y: number }} me
* @param {{ worldZoos?: Array<unknown> }} zoosData
* @returns {import("./types.js").GameState}
*/
export function bootstrapMeWithGameState(me, zoosData) {
const s = me.game_state;
const myZooId = me.zooId;
s.myZooId = myZooId;
s.playerName = me.name;
s.playerX = me.x;
s.playerY = me.y;
if (s.coins < 100) s.coins = 200;
const nowUnix = Math.floor(Date.now() / 1000);
if (s.conveyorOffers === null || s.conveyorOffers === undefined || s.conveyorOffers.length === 0) {
refreshOffers(s, nowUnix);
}
applyWorldZoos(s, { zoosData, playerZooId: myZooId, playerName: me.name, playerX: me.x, playerY: me.y });
return s;
}
/**
* @param {{ name?: string }} me
* @param {{ worldZoos?: Array<unknown> }} zoosData
* @returns {Promise<import("./types.js").GameState>}
*/
export async function bootstrapMeCreateZoo(me, zoosData) {
const s = defaultState();
const nowUnix = Math.floor(Date.now() / 1000);
refreshOffers(s, nowUnix);
const created = await createMyZoo(me.name || "Mon zoo", s);
const myZooId = created.zooId;
applyWorldZoos(s, { zoosData, playerZooId: myZooId, playerName: created.name, playerX: created.x, playerY: created.y });
s.myZooId = myZooId;
s.playerName = created.name;
s.playerX = created.x;
s.playerY = created.y;
return s;
}
/**
* @param {(zooId: string) => void} setMyZooId
* @param {HTMLElement} rootEl
* @returns {Promise<import("./types.js").GameState>}
*/
export async function bootstrapFromApi(setMyZooId, rootEl) {
const keys = await getOrCreateKeyPair();
if (!keys) {
return bootstrapNoKeys();
}
let zoosData = { worldZoos: [], mapWidth: 100, mapHeight: 100 };
try {
zoosData = await loadZoos();
} catch (e) {
console.warn("loadZoos failed", e);
}
const meRes = await loadMyZoo().catch((e) => {
console.warn("loadMyZoo failed", e);
return { status: 401 };
});
if (meRes.status === 401) {
return bootstrapShowRegisterPanel(zoosData, rootEl, setMyZooId);
}
if (meRes.status === 404) {
const s = await bootstrapMe404(zoosData);
setMyZooId(s.myZooId ?? "player");
return s;
}
const me = meRes.data;
if (me.game_state && typeof me.game_state === "object") {
setMyZooId(me.zooId);
return bootstrapMeWithGameState(me, zoosData);
}
const s = await bootstrapMeCreateZoo(me, zoosData);
setMyZooId(s.myZooId ?? "player");
return s;
}

193
web/js/main.js Normal file
View File

@@ -0,0 +1,193 @@
import { defaultState, saveState } from "./state.js";
import { refreshOffers } from "./conveyor.js";
import { render } from "./ui.js";
import { startGameLoop } from "./game-loop.js";
import { playSound, setMusicEnabled, setMusicGetState } from "./audio.js";
import { resolveIncident, INCIDENT_EMOJI } from "./visitor-incidents.js";
import { getAttractionCenter, getVisitorPosition } from "./visitor-attraction.js";
import { incidentLabel, incidentBubbleAria } from "./texts-fr.js";
import { getApiBase, loadZoos, saveMyZoo, setApiBaseUrl } from "./api-client.js";
import { bootstrapFromApi, applyWorldZoos } from "./main-bootstrap.js";
const root = document.getElementById("root");
if (!root) throw new Error("Missing #root");
let state = null;
let myZooId = null;
function setMyZooId(id) {
myZooId = id;
}
(async () => {
let base = getApiBase();
if (!base) {
root.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1>" +
"<p>Connectez-vous à un serveur pour jouer (compte et sauvegarde en base).</p>" +
"<div style=\"margin-top: 1rem;\"><label for=\"boot-api-url\">URL du serveur</label><input id=\"boot-api-url\" type=\"text\" placeholder=\"https://...\" style=\"display:block;margin-top:4px;width:100%;\" /></div>" +
"<button id=\"boot-connect\" type=\"button\" style=\"margin-top: 8px;\">Se connecter</button>" +
"<p id=\"boot-err\" class=\"boot-err\"></p></div>";
const urlInput = document.getElementById("boot-api-url");
try {
const stored = localStorage.getItem("builazoo_api_url");
if (stored) urlInput.value = stored;
} catch (_) {
// ignore localStorage
}
const errEl = document.getElementById("boot-err");
await new Promise((resolve) => {
document.getElementById("boot-connect").addEventListener("click", () => {
const url = urlInput.value.trim();
if (!url) {
errEl.textContent = "Indiquez l'URL du serveur.";
return;
}
setApiBaseUrl(url);
resolve();
});
});
root.innerHTML = "";
base = getApiBase();
}
if (base) {
root.innerHTML = "<div class=\"boot-panel\"><p>Chargement…</p></div>";
while (true) {
try {
state = await bootstrapFromApi(setMyZooId, root);
break;
} catch (e) {
console.error("bootstrapFromApi failed", e);
root.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1><p class=\"boot-err\">Erreur de connexion au serveur.</p><button id=\"boot-retry\" type=\"button\">Réessayer</button></div>";
const errP = root.querySelector(".boot-err");
if (errP && e && e.message) errP.textContent = e.message;
await new Promise((resolve) => {
document.getElementById("boot-retry").addEventListener("click", () => resolve());
});
}
}
root.innerHTML = "";
}
if (state) {
try {
if (localStorage.getItem("builazoo_music") === "1") setMusicEnabled(true);
} catch (_) {
// ignore localStorage
}
let lastHatched = [];
let fullRender = () => {};
function getState() {
return state;
}
function doRestart() {
state = defaultState();
const nowUnix = Math.floor(Date.now() / 1000);
refreshOffers(state, nowUnix);
saveState(state);
fullRender();
}
fullRender = render(root, {
state: getState(),
setState: () => fullRender(),
getLastHatched: () => lastHatched,
onRestart: doRestart,
updateState: (partial) => {
Object.assign(state, partial);
fullRender();
},
});
let lastApiSaveAt = 0;
const MIN_API_SAVE_INTERVAL_MS = 5000;
function saveStateFn(s) {
saveState(s);
if (getApiBase()) {
const now = Date.now();
if (now - lastApiSaveAt >= MIN_API_SAVE_INTERVAL_MS) {
lastApiSaveAt = now;
saveMyZoo(s).catch((e) => console.warn("saveMyZoo failed", e));
}
}
}
setMusicGetState(getState);
startGameLoop(getState, (s, payload) => {
if (payload?.lastHatched?.length) {
lastHatched = payload.lastHatched;
playSound("hatch");
}
fullRender();
setTimeout(() => { lastHatched = []; fullRender(); }, 1800);
}, saveStateFn);
const ZOOS_REFETCH_INTERVAL_MS = 30 * 1000;
setInterval(() => {
loadZoos().then((zoosData) => {
applyWorldZoos(state, { zoosData, playerZooId: state.myZooId ?? myZooId, playerName: state.playerName ?? "Mon zoo", playerX: state.playerX ?? 25, playerY: state.playerY ?? 50 });
fullRender();
}).catch(() => {});
}, ZOOS_REFETCH_INTERVAL_MS);
let visitorAnimTime = 0;
function syncVisitorBubble(el, visitor, index) {
const incidentType = visitor && (visitor.incidentType === null || visitor.incidentType === undefined)
? null
: (visitor && visitor.incidentType);
let bubble = el.querySelector(".visitor-incident-bubble");
if (incidentType) {
if (!bubble) {
bubble = document.createElement("span");
bubble.className = "visitor-incident-bubble";
bubble.setAttribute("role", "button");
bubble.setAttribute("tabindex", "0");
bubble.setAttribute("aria-label", incidentBubbleAria);
bubble.addEventListener("click", () => {
if (resolveIncident(getState(), index)) fullRender();
});
el.appendChild(bubble);
}
bubble.textContent = INCIDENT_EMOJI[incidentType] ?? "❓";
bubble.title = incidentLabel[incidentType] ?? incidentType;
} else if (bubble) {
bubble.remove();
}
}
function updateVisitors() {
const layer = root.querySelector(".visitors-layer");
if (!layer) {
requestAnimationFrame(updateVisitors);
return;
}
const currentState = getState();
const arrivals = currentState.visitorArrivals ?? [];
const n = arrivals.length;
const w = currentState.grid.width;
const h = currentState.grid.height;
while (layer.children.length < n) {
const el = document.createElement("div");
el.className = "visitor-sprite";
el.setAttribute("aria-hidden", "true");
el.textContent = "👤";
layer.appendChild(el);
}
while (layer.children.length > n) {
const last = layer.lastChild;
if (last) last.remove();
}
const { centerX, centerY } = getAttractionCenter(currentState, w, h);
for (let i = 0; i < n; i++) {
const el = layer.children[i];
const { px, py } = getVisitorPosition({
i, n, t: visitorAnimTime, centerX, centerY, gridWidth: w, gridHeight: h,
});
el.style.left = `${px}px`;
el.style.top = `${py}px`;
syncVisitorBubble(el, arrivals[i], i);
}
visitorAnimTime += 0.016;
requestAnimationFrame(updateVisitors);
}
requestAnimationFrame(updateVisitors);
}
})();

27
web/js/mutation-rules.js Normal file
View File

@@ -0,0 +1,27 @@
const Definitions = {
none: { incomeMultiplier: 1.0, weight: 0 },
golden: { incomeMultiplier: 1.5, weight: 50 },
crystal: { incomeMultiplier: 1.8, weight: 30 },
void: { incomeMultiplier: 2.2, weight: 20 },
};
/**
* @returns {Array<{ id: string, weight: number }>}
*/
export function getMutationEntries() {
const entries = [];
for (const [id, def] of Object.entries(Definitions)) {
if (id !== "none") entries.push({ id, weight: def.weight });
}
return entries;
}
/**
* @param {string} mutationId
* @returns {number}
*/
export function getIncomeMultiplier(mutationId) {
const def = Definitions[mutationId];
if (def === null || def === undefined) return 1.0;
return def.incomeMultiplier;
}

332
web/js/placement.js Normal file
View File

@@ -0,0 +1,332 @@
import { cellKey, withinBounds, getBlockKeys } from "./grid-utils.js";
import {
getNurseryBuildCost,
getNurseryUpgradeCost,
getSouvenirShopBuildCost,
getSouvenirShopUpgradeCost,
getBuildingBuildCost,
getBuildingUpgradeCost,
getBuildingMaxLevel,
BUILDING_KINDS,
} from "./economy.js";
import { GameConfig } from "./config.js";
/**
* All keys that belong to the same animal block as the cell at (x, y). If not an animal or single-cell, returns [cellKey(x,y)].
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {string[]}
*/
export function getBlockKeysFromCell(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "animal") return [key];
let ox = x;
let oy = y;
let w = cell.cellsWide ?? 1;
let h = cell.cellsHigh ?? 1;
if (cell.originKey !== null && cell.originKey !== undefined) {
const m = cell.originKey.match(/^(\d+)_(\d+)$/);
if (m) {
ox = Number(m[1]);
oy = Number(m[2]);
const origin = state.grid.cells[cell.originKey];
if (origin && origin.kind === "animal") {
w = origin.cellsWide ?? 1;
h = origin.cellsHigh ?? 1;
}
}
}
return getBlockKeys(ox, oy, w, h);
}
/**
* Check if a rectangular block can be placed (all cells empty and in bounds). Optionally exclude keys that belong to excludeOriginKey block.
* @param {import("./types.js").GameState} state
* @param {{ originX: number, originY: number, w: number, h: number, excludeOriginKey?: string }} opts
* @returns {[boolean, string?]}
*/
export function canPlaceMultiCell(state, opts) {
const { originX, originY, w, h, excludeOriginKey } = opts;
const excludeSet = new Set();
if (excludeOriginKey !== null && excludeOriginKey !== undefined) {
const orig = state.grid.cells[excludeOriginKey];
if (orig && orig.kind === "animal") {
const [ox, oy] = excludeOriginKey.split("_").map(Number);
const ow = orig.cellsWide ?? 1;
const oh = orig.cellsHigh ?? 1;
getBlockKeys(ox, oy, ow, oh).forEach((k) => excludeSet.add(k));
}
}
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
const nx = originX + dx;
const ny = originY + dy;
if (!withinBounds(state.grid.width, state.grid.height, nx, ny)) return [false, "OutOfBounds"];
const k = cellKey(nx, ny);
if (!excludeSet.has(k)) {
const c = state.grid.cells[k];
if (c !== null && c !== undefined) return [false, "Occupied"];
}
}
}
return [true, undefined];
}
/**
* Fill a block with the same animal data (originKey set to origin, each cell gets full copy).
* @param {import("./types.js").GameState} state
* @param {number} originX
* @param {number} originY
* @param {import("./types.js").AnimalCell} animalData
*/
export function fillAnimalBlock(state, originX, originY, animalData) {
const w = animalData.cellsWide ?? 1;
const h = animalData.cellsHigh ?? 1;
const originKey = cellKey(originX, originY);
const data = { ...animalData, originKey };
for (const k of getBlockKeys(originX, originY, w, h)) {
state.grid.cells[k] = { ...data };
}
}
/**
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, string?]}
*/
export function canPlace(state, x, y) {
if (!withinBounds(state.grid.width, state.grid.height, x, y)) return [false, "OutOfBounds"];
const key = cellKey(x, y);
if (state.grid.cells[key] !== null && state.grid.cells[key] !== undefined) return [false, "Occupied"];
return [true, undefined];
}
/**
* @param {import("./types.js").GameState} state
* @param {{ eggType: string, tokenId: number, x: number, y: number, hatchAt: number, seed: number }} opts
* @returns {[boolean, string?]}
*/
export function placeEgg(state, opts) {
const { eggType, tokenId, x, y, hatchAt, seed } = opts;
const [ok, reason] = canPlace(state, x, y);
if (!ok) return [false, reason];
const key = cellKey(x, y);
state.grid.cells[key] = {
kind: "egg",
eggType,
tokenId,
hatchAt,
seed,
};
return [true, undefined];
}
/**
* Déplace le contenu d'une case vers une case vide (œuf ou animal). For multi-cell animals, moves the whole block.
* @param {import("./types.js").GameState} state
* @param {{ fromX: number, fromY: number, toX: number, toY: number }} opts
* @returns {[boolean, string?]}
*/
export function moveCell(state, opts) {
const { fromX, fromY, toX, toY } = opts;
const fromKey = cellKey(fromX, fromY);
const toKey = cellKey(toX, toY);
if (fromKey === toKey) return [false, "SameCell"];
const source = state.grid.cells[fromKey];
if (source === null || source === undefined) return [false, "NoSource"];
if (source.kind === "animal") {
const blockKeys = getBlockKeysFromCell(state, fromX, fromY);
let ox = fromX;
let oy = fromY;
let w = source.cellsWide ?? 1;
let h = source.cellsHigh ?? 1;
if (source.originKey !== null && source.originKey !== undefined) {
const m = source.originKey.match(/^(\d+)_(\d+)$/);
if (m) {
ox = Number(m[1]);
oy = Number(m[2]);
const origin = state.grid.cells[source.originKey];
if (origin && origin.kind === "animal") {
w = origin.cellsWide ?? 1;
h = origin.cellsHigh ?? 1;
}
}
}
const originKey = cellKey(ox, oy);
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h, excludeOriginKey: originKey });
if (!ok) return [false, reason];
const animalData = { ...source, originKey: toKey, cellsWide: w, cellsHigh: h };
for (const k of blockKeys) delete state.grid.cells[k];
fillAnimalBlock(state, toX, toY, animalData);
return [true, undefined];
}
const [ok, reason] = canPlace(state, toX, toY);
if (!ok) return [false, reason];
state.grid.cells[toKey] = source;
delete state.grid.cells[fromKey];
return [true, undefined];
}
/**
* Set an empty cell to nursery. Cell must be empty. Costs coins.
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, string?]}
*/
export function tryBuildNursery(state, x, y) {
const [ok, reason] = canPlace(state, x, y);
if (!ok) return [ok, reason];
const cost = getNurseryBuildCost();
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
state.grid.cells[cellKey(x, y)] = { kind: "nursery", level: 1 };
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
return [true, undefined];
}
/**
* Set an empty cell to souvenir shop. Cell must be empty. Costs coins.
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, string?]}
*/
export function tryBuildSouvenirShop(state, x, y) {
const [ok, reason] = canPlace(state, x, y);
if (!ok) return [ok, reason];
const cost = getSouvenirShopBuildCost();
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
state.grid.cells[cellKey(x, y)] = { kind: "souvenirShop", level: 1 };
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
return [true, undefined];
}
/**
* Upgrade a nursery cell. Cell must be nursery and below max level.
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, string?]}
*/
export function tryUpgradeNursery(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "nursery") return [false, "NotNursery"];
const maxLevel = GameConfig.Nursery?.MaxLevel ?? 5;
const level = cell.level ?? 1;
if (level >= maxLevel) return [false, "NurseryMaxLevel"];
const cost = getNurseryUpgradeCost(level);
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
cell.level = level + 1;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
return [true, undefined];
}
/**
* Upgrade a souvenir shop cell. Cell must be souvenirShop and below max level.
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, string?]}
*/
export function tryUpgradeSouvenirShop(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "souvenirShop") return [false, "NotSouvenirShop"];
const maxLevel = GameConfig.SouvenirShop?.MaxLevel ?? 5;
const level = cell.level ?? 1;
if (level >= maxLevel) return [false, "SouvenirShopMaxLevel"];
const cost = getSouvenirShopUpgradeCost(level);
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
cell.level = level + 1;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
return [true, undefined];
}
/**
* Build a building of the given kind on an empty cell. Used by research, billeterie, food, reception, biomeChangeColor, biomeChangeTemp.
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @param {typeof BUILDING_KINDS[number]} kind
* @returns {[boolean, string?]}
*/
export function tryBuildBuilding(state, x, y, kind) {
if (!BUILDING_KINDS.includes(kind)) return [false, "UnknownBuilding"];
const [ok, reason] = canPlace(state, x, y);
if (!ok) return [ok, reason];
const cost = getBuildingBuildCost(kind);
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
state.grid.cells[cellKey(x, y)] = { kind, level: 1 };
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
return [true, undefined];
}
/**
* Upgrade a building cell of the given kind. Cell must be that kind and below max level.
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @param {typeof BUILDING_KINDS[number]} kind
* @returns {[boolean, string?]}
*/
export function tryUpgradeBuilding(state, x, y, kind) {
if (!BUILDING_KINDS.includes(kind)) return [false, "UnknownBuilding"];
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== kind) return [false, `Not${kind.charAt(0).toUpperCase() + kind.slice(1)}`];
const maxLevel = getBuildingMaxLevel(kind);
const level = cell.level ?? 1;
if (level >= maxLevel) return [false, `${kind.charAt(0).toUpperCase() + kind.slice(1)}MaxLevel`];
const cost = getBuildingUpgradeCost(kind, level);
if (state.coins < cost) return [false, "NotEnoughCoins"];
state.coins -= cost;
cell.level = level + 1;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
return [true, undefined];
}
export function tryBuildResearch(state, x, y) {
return tryBuildBuilding(state, x, y, "research");
}
export function tryUpgradeResearch(state, x, y) {
return tryUpgradeBuilding(state, x, y, "research");
}
export function tryBuildBilleterie(state, x, y) {
return tryBuildBuilding(state, x, y, "billeterie");
}
export function tryUpgradeBilleterie(state, x, y) {
return tryUpgradeBuilding(state, x, y, "billeterie");
}
export function tryBuildFood(state, x, y) {
return tryBuildBuilding(state, x, y, "food");
}
export function tryUpgradeFood(state, x, y) {
return tryUpgradeBuilding(state, x, y, "food");
}
export function tryBuildReception(state, x, y) {
return tryBuildBuilding(state, x, y, "reception");
}
export function tryUpgradeReception(state, x, y) {
return tryUpgradeBuilding(state, x, y, "reception");
}
export function tryBuildBiomeChangeColor(state, x, y) {
return tryBuildBuilding(state, x, y, "biomeChangeColor");
}
export function tryUpgradeBiomeChangeColor(state, x, y) {
return tryUpgradeBuilding(state, x, y, "biomeChangeColor");
}
export function tryBuildBiomeChangeTemp(state, x, y) {
return tryBuildBuilding(state, x, y, "biomeChangeTemp");
}
export function tryUpgradeBiomeChangeTemp(state, x, y) {
return tryUpgradeBuilding(state, x, y, "biomeChangeTemp");
}

56
web/js/prestige.js Normal file
View File

@@ -0,0 +1,56 @@
import { GameConfig } from "./config.js";
import { plotSizeFromLevel } from "./grid-utils.js";
import { buildDefaultRow1Cells, addStarterAnimals } from "./default-grid-layout.js";
/**
* @param {import("./types.js").GameState} state
* @returns {boolean}
*/
export function canPrestige(state) {
return (state.coins ?? 0) >= GameConfig.Prestige.MinCoinsToReset;
}
/**
* @param {import("./types.js").GameState} state
* @returns {import("./types.js").GameState} new state after reset
*/
export function doPrestige(state) {
if (!canPrestige(state)) return state;
const newLevel = (state.prestigeLevel ?? 0) + 1;
const [width, height] = plotSizeFromLevel(1);
state.coins = 0;
state.conveyorLevel = 1;
state.plotLevel = 1;
state.truckLevel = 1;
const cells = buildDefaultRow1Cells();
state.grid = { width, height, cells };
addStarterAnimals(state);
state.worldMapLevel = 1;
state.pendingEggTokens = [];
state.pendingBabies = [];
state.receptionAnimals = [];
state.nextTokenId = 1;
state.conveyorOffers = [];
state.lastOfferRefreshAt = 0;
state.worldTruckSales = [];
delete state.truckSale;
state.laboratoryOffer = null;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
state.prestigeLevel = newLevel;
state.timeOfDay = 6;
state.weather = "sun";
state.lastWeatherChangeAt = 0;
state.quests = [];
state.lastQuestDay = "";
state.stats = { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 };
return state;
}
/**
* @param {number} prestigeLevel
* @returns {number}
*/
export function getPrestigeIncomeMultiplier(prestigeLevel) {
const level = prestigeLevel ?? 0;
return 1 + level * GameConfig.Prestige.IncomeBonusPerLevel;
}

98
web/js/quests.js Normal file
View File

@@ -0,0 +1,98 @@
import { GameConfig } from "./config.js";
const QUEST_TEMPLATES = [
{ descriptionKey: "questPlaceEggs", targetKey: "eggsPlaced", targetBase: 3 },
{ descriptionKey: "questEarnCoins", targetKey: "coinsEarned", targetBase: 100 },
{ descriptionKey: "questSellAnimals", targetKey: "animalsSold", targetBase: 2 },
{ descriptionKey: "questUpgradeConveyor", targetKey: "conveyorUpgrades", targetBase: 1 },
{ descriptionKey: "questUpgradePlot", targetKey: "plotUpgrades", targetBase: 1 },
];
/**
* @param {string} dateKey YYYY-MM-DD
* @returns {number}
*/
function daySeed(dateKey) {
let h = 0;
for (let i = 0; i < dateKey.length; i++) h = (h * 31 + dateKey.charCodeAt(i)) >>> 0;
return h;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} level
* @returns {number}
*/
function _questReward(state, level) {
return GameConfig.Quests.RewardBase + level * GameConfig.Quests.RewardPerLevel;
}
/**
* @param {import("./types.js").GameState} state
* @returns {import("./types.js").Quest[]}
*/
export function generateDailyQuests(state) {
const today = new Date().toISOString().slice(0, 10);
if (state.lastQuestDay === today && state.quests?.length > 0) return state.quests;
state.lastQuestDay = today;
let seed = daySeed(today);
const rng = () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) / 65536;
};
const shuffled = [...QUEST_TEMPLATES].sort(() => rng() - 0.5);
const count = Math.min(GameConfig.Quests.CountPerDay, shuffled.length);
const level = (state.prestigeLevel ?? 0) + state.plotLevel + state.conveyorLevel;
state.quests = shuffled.slice(0, count).map((q, i) => ({
id: `q-${today}-${i}`,
descriptionKey: q.descriptionKey,
target: q.targetBase,
current: 0,
reward: GameConfig.Quests.RewardBase + level * GameConfig.Quests.RewardPerLevel,
done: false,
}));
return state.quests;
}
/** @param {import("./types.js").GameState} state */
function getQuestProgress(state) {
const s = state.stats ?? { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, coinsEarned: 0 };
return {
eggsPlaced: s.eggsPlaced ?? 0,
animalsSold: s.animalsSold ?? 0,
conveyorUpgrades: s.conveyorUpgrades ?? 0,
plotUpgrades: s.plotUpgrades ?? 0,
coinsEarned: s.coinsEarned ?? 0,
};
}
/**
* @param {import("./types.js").GameState} state
* @returns {number} coins awarded from completed quests this tick
*/
export function tickQuests(state) {
generateDailyQuests(state);
const progress = getQuestProgress(state);
let earned = 0;
for (const q of state.quests ?? []) {
if (q.done) {
// already done
} else {
let current = null;
if (q.descriptionKey === "questPlaceEggs") current = progress.eggsPlaced;
else if (q.descriptionKey === "questSellAnimals") current = progress.animalsSold;
else if (q.descriptionKey === "questUpgradeConveyor") current = progress.conveyorUpgrades;
else if (q.descriptionKey === "questUpgradePlot") current = progress.plotUpgrades;
else if (q.descriptionKey === "questEarnCoins") current = progress.coinsEarned ?? 0;
if (current !== null && current !== undefined) {
q.current = Math.min(current, q.target);
if (q.current >= q.target) {
q.done = true;
earned += q.reward;
}
}
}
}
state.coins += earned;
return earned;
}

231
web/js/reproduction.js Normal file
View 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;
}

232
web/js/state.js Normal file
View File

@@ -0,0 +1,232 @@
import { GameConfig } from "./config.js";
import { plotSizeFromLevel } from "./grid-utils.js";
import { LootTables, getColorNames, zeroAnimalWeights } from "./loot-tables.js";
import { ensureBotState } from "./bot-zoo.js";
import { buildDefaultRow1Cells, addStarterAnimals } from "./default-grid-layout.js";
export function defaultAnimalWeights() {
const w = zeroAnimalWeights();
w[getColorNames()[0]] = 1;
return w;
}
export function normalizeZooWeights(legacy) {
if (!legacy || typeof legacy !== "object") return defaultAnimalWeights();
const keys = getColorNames();
const w = zeroAnimalWeights();
const map = { Basic: keys[0], Ocean: keys[5], Mountain: keys[10] };
for (const [oldKey, val] of Object.entries(legacy)) {
const newKey = map[oldKey] ?? oldKey;
if (keys.includes(newKey)) w[newKey] = Number(val) || 0;
}
return w;
}
/**
* @returns {import("./types.js").GameState}
*/
export function defaultState() {
const [width, height] = plotSizeFromLevel(1);
const worldZoos = buildDefaultWorldZoos();
const cells = buildDefaultCells();
const state = buildStatePayload(width, height, worldZoos, cells);
addStarterAnimals(state);
return state;
}
function buildDefaultWorldZoos() {
const configZoos = GameConfig.WorldMap?.Zoos;
if (configZoos && configZoos.length > 0) {
const worldZoos = configZoos.map((z, i) => ({
id: z.id,
name: z.name,
x: z.x,
y: z.y,
animalWeights: i === 0 ? defaultAnimalWeights() : normalizeZooWeights(z.animalWeights),
}));
worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player"));
return worldZoos;
}
return [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }];
}
function buildDefaultCells() {
return buildDefaultRow1Cells();
}
function buildStatePayload(width, height, worldZoos, cells) {
return {
version: GameConfig.StateVersion,
coins: 200,
conveyorLevel: 1,
plotLevel: 1,
truckLevel: 1,
grid: { width, height, cells },
pendingEggTokens: [],
nextTokenId: 1,
conveyorOffers: [],
lastOfferRefreshAt: 0,
worldZoos,
truckSale: undefined,
worldTruckSales: [],
lastEvolutionAt: Math.floor(Date.now() / 1000),
laboratoryOffer: null,
prestigeLevel: 0,
timeOfDay: 6,
weather: "sun",
lastWeatherChangeAt: 0,
quests: [],
lastQuestDay: "",
stats: { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 },
mapZoom: 1,
mapPanX: 0,
mapPanY: 0,
worldMapLevel: 1,
autoMode: false,
autoModeProfile: "balanced",
researchPoints: 0,
pendingBabies: [],
receptionAnimals: [],
saleListings: [],
deathCountRecent: 0,
birthCount: 0,
reproductionTimers: [],
visitorArrivals: [],
};
}
const STORAGE_KEY = "builazoo_state";
/**
* @param {import("./types.js").GameState} state
*/
export function saveState(state) {
try {
const toSave = { ...state };
delete toSave.autoProfilePickerOpen;
delete toSave.autoProfilePickerFamily;
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {
console.error("saveState failed", e);
}
}
/**
* @returns {import("./types.js").GameState | null}
*/
export function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null || raw === undefined) return null;
const data = JSON.parse(raw);
if (!data || typeof data.coins !== "number" || !data.grid || typeof data.grid.cells !== "object") return null;
applyLoadStateDefaults(data);
normalizeLoadedCells(data.grid.cells);
ensureSchoolCell(data);
return data;
} catch (e) {
console.error("loadState failed", e);
return null;
}
}
function applyLoadStateDefaults(data) {
applyLoadStateWorldZoos(data);
applyLoadStateScalarDefaults(data);
applyLoadStateLegacyCells(data);
}
function applyLoadStateWorldZoos(data) {
if (data.coins < 100) data.coins = 200;
if (data.pendingEggTokens === null || data.pendingEggTokens === undefined) data.pendingEggTokens = [];
if (data.conveyorOffers === null || data.conveyorOffers === undefined) data.conveyorOffers = [];
data.conveyorOffers = data.conveyorOffers.map((o) => ({ ...o, zooId: o.zooId ?? "player" }));
if ((data.worldZoos === null || data.worldZoos === undefined) && GameConfig.WorldMap && GameConfig.WorldMap.Zoos) {
data.worldZoos = [...GameConfig.WorldMap.Zoos];
}
if (data.worldZoos !== null && data.worldZoos !== undefined && Array.isArray(data.worldZoos)) {
const keys = getColorNames();
data.worldZoos = data.worldZoos.map((z, _i) => ({
...z,
animalWeights: z.animalWeights && keys.some((k) => k in (z.animalWeights ?? {}))
? z.animalWeights
: normalizeZooWeights(z.animalWeights),
}));
data.worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player"));
}
if (data.worldZoos === null || data.worldZoos === undefined) data.worldZoos = [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }];
}
/** Set data[key] to defaultVal when data[key] is null or undefined. defaultVal may be a function (called for value). */
function setScalarDefault(data, key, defaultVal) {
if (data[key] === null || data[key] === undefined) {
data[key] = typeof defaultVal === "function" ? defaultVal() : defaultVal;
}
}
const LOAD_STATE_SCALAR_DEFAULTS = [
["worldTruckSales", []],
["lastEvolutionAt", () => Math.floor(Date.now() / 1000)],
["nextTokenId", 1],
["prestigeLevel", 0],
["timeOfDay", 6],
["weather", "sun"],
["lastWeatherChangeAt", 0],
["quests", []],
["lastQuestDay", ""],
["stats", { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 }],
["truckLevel", 1],
["mapZoom", 1],
["mapPanX", 0],
["mapPanY", 0],
["worldMapLevel", 1],
["researchPoints", 0],
["pendingBabies", []],
["receptionAnimals", []],
["saleListings", []],
["deathCountRecent", 0],
["birthCount", 0],
["reproductionTimers", []],
["visitorArrivals", []],
];
function applyLoadStateScalarDefaults(data) {
if (data.laboratoryOffer === undefined) data.laboratoryOffer = null;
for (const [key, defaultVal] of LOAD_STATE_SCALAR_DEFAULTS) {
setScalarDefault(data, key, defaultVal);
}
if (data.version !== GameConfig.StateVersion) data.version = GameConfig.StateVersion;
data.autoProfilePickerOpen = false;
data.autoProfilePickerFamily = undefined;
if (data.attractivityBonusFromIncidents === null || data.attractivityBonusFromIncidents === undefined) {
data.attractivityBonusFromIncidents = 0;
}
}
function applyLoadStateLegacyCells(data) {
if (data.grid.cells["2_1"] === null || data.grid.cells["2_1"] === undefined) data.grid.cells["2_1"] = { kind: "nursery", level: 1 };
const c21 = data.grid.cells["2_1"];
if (c21 && (c21.kind === "plotUpgrade" || c21.kind === "worldMapUpgrade")) data.grid.cells["2_1"] = { kind: "nursery", level: 1 };
const c12 = data.grid.cells["1_2"];
if (c12 && (c12.kind === "plotUpgrade" || c12.kind === "worldMapUpgrade")) delete data.grid.cells["1_2"];
}
function normalizeLoadedCells(cells) {
const now = Math.floor(Date.now() / 1000);
for (const key of Object.keys(cells)) {
const cell = cells[key];
if (cell) {
if (cell.kind === "animal" && cell.id && !LootTables.Animals[cell.id]) cell.id = "c0_r0";
if (cell.kind === "animal" && (cell.lastVisitedAt === null || cell.lastVisitedAt === undefined)) cell.lastVisitedAt = now;
if (cell.kind === "animal" && (cell.lastFedAt === null || cell.lastFedAt === undefined)) cell.lastFedAt = cell.placedAt ?? now;
if (cell.kind === "egg" && cell.eggType && !LootTables.EggTypes[cell.eggType]) cell.eggType = "Color_1";
}
}
}
function ensureSchoolCell(data) {
const hasSchool = Object.values(data.grid.cells).some((c) => c && c.kind === "school");
if (!hasSchool && !data.grid.cells["1_1"] && (data.conveyorLevel || 0) >= 1) {
data.grid.cells["1_1"] = { kind: "school", level: data.conveyorLevel || 1 };
}
}

175
web/js/texts-fr.js Normal file
View File

@@ -0,0 +1,175 @@
/** Textes et libellés en français */
import { getColorNames } from "./loot-tables.js";
const colorNames = getColorNames();
export const eggTypeLabel = Object.fromEntries(
colorNames.map((key, i) => [key, `Couleur ${i + 1}`])
);
const animalLabelEntries = [];
for (let c = 0; c < 15; c++) {
for (let r = 0; r < 5; r++) {
const id = `c${c}_r${r}`;
animalLabelEntries.push([id, `C${c + 1} Niv.${r + 1}`]);
}
}
export const animalLabel = Object.fromEntries(animalLabelEntries);
export const rarityLabel = {
"1": "Niveau 1",
"2": "Niveau 2",
"3": "Niveau 3",
"4": "Niveau 4",
"5": "Niveau 5",
Common: "Commun",
Uncommon: "Peu commun",
Rare: "Rare",
Epic: "Épique",
Legendary: "Légendaire",
};
export const t = {
title: "Construis un zoo",
plotTitle: "Parcelle",
zooTabTitle: "Carte du zoo",
worldMapTabTitle: "Carte du monde",
statusTemplate: "Pièces : %d · Parcelle %d · Compétences %d · Case : %d,%d · Œufs à placer : %d",
conveyorHint: "Cliquez sur un œuf sur le tapis pour lacheter, puis sur une case pour le placer. Glissez-déposez un œuf ou un animal pour le déplacer.",
buyFailed: "Achat impossible : %s",
boughtToken: "Œuf %s acheté (à placer sur la grille).",
upgradeConveyor: "Améliorer le tapis",
upgradeConveyorCost: "%d pièces",
upgradePlot: "Agrandir la parcelle",
upgradePlotCost: "%d pièces",
sellAnimal: "Vendre (glisser lanimal sur le camion)",
upgradeConveyorFailed: "Amélioration tapis impossible : %s",
upgradePlotFailed: "Agrandissement impossible : %s",
upgradeWorldMapFailed: "Agrandissement carte impossible : %s",
sellFailed: "Vente impossible : %s",
errorPrefix: "Erreur : %s",
noOffer: "—",
helpConveyor: "Carte du monde : dautres zoos (et le vôtre) proposent des œufs. Plus vos compétences sont développées, plus vous voyez de zoos avec des œufs chers. Cliquez sur un œuf pour lacheter.",
helpGrid: "Cliquez sur une case pour la sélectionner. Si vous avez un œuf acheté, un clic place lœuf ici. Glissez un œuf ou un animal vers une case vide pour le déplacer. Glissez un animal sur le camion pour le vendre à un autre zoo.",
helpUpgradeConveyor: "Développe les compétences : accès à plus de zoos et types dœufs (Océan, Montagne).",
helpUpgradePlot: "Agrandit la grille pour placer plus danimaux.",
helpUpgradeWorldMap: "Agrandit la vue de la carte du monde (zoom).",
helpSell: "Glissez un animal depuis la grille et déposez-le sur le camion pour le vendre à un autre zoo.",
helpStatus: "Pièces, niveau de parcelle, case sélectionnée, niveau de compétences, visiteurs, œufs à vendre sur la carte.",
};
/** Messages d'erreur pour les codes renvoyés par le jeu */
export const errorMessage = {
OfferUnavailable: "Cette offre nest plus disponible.",
NotEnoughCoins: "Pas assez de pièces.",
UnknownEgg: "Type dœuf inconnu.",
InvalidToken: "Œuf invalide ou déjà placé.",
OutOfBounds: "Case hors de la grille.",
Occupied: "Case déjà occupée.",
ConveyorMaxLevel: "Compétences déjà au niveau max.",
TruckMaxLevel: "Camion déjà au niveau max.",
PlotMaxLevel: "Parcelle déjà au niveau max.",
WorldMapMaxLevel: "Carte du monde déjà au niveau max.",
NotEnoughResearch: "Pas assez dunités de recherche.",
NoAnimal: "Aucun animal sur cette case.",
NoSchool: "Aucune école sur cette case.",
SameCell: "Source et destination identiques.",
NoSource: "Aucun objet sur la case de départ.",
NurseryMaxLevel: "Nurserie au niveau max.",
SouvenirShopMaxLevel: "Boutique au niveau max.",
NotNursery: "Ce nest pas une nurserie.",
NotSouvenirShop: "Ce nest pas une boutique.",
BabyNotMature: "Le bébé nest pas encore prêt.",
NoBabyInNursery: "Aucun bébé dans cette nurserie.",
AnimalNotReady: "Lanimal nest pas encore prêt (accueil).",
NoAnimalInReception: "Aucun animal dans cet accueil.",
};
export const questDescription = {
questPlaceEggs: "Placer %d œuf(s)",
questEarnCoins: "Gagner %d pièces",
questSellAnimals: "Vendre %d animal(aux)",
questUpgradeConveyor: "Développer les compétences (%d fois)",
questUpgradePlot: "Agrandir la parcelle (%d fois)",
};
export const timePhaseLabel = { dawn: "Aube", day: "Jour", dusk: "Crépuscule", night: "Nuit" };
export const weatherLabel = { sun: "Ensoleillé", cloudy: "Nuageux", rain: "Pluie" };
export const prestigeLabel = "Prestige (reset avec bonus permanent)";
export const prestigeButton = "Réinitialiser (Prestige +%d)";
export const prestigeHint = "Réinitialise tout et ajoute un bonus permanent de revenus. Coût min. : %d pièces.";
export const visitorsLabel = "Visiteurs";
export const musicLabel = "Musique";
export const incidentLabel = {
thirst: "Soif",
bin: "Poubelle pleine",
bench: "Banc requis",
animalFar: "Animal trop loin",
photo: "Envie de photo",
};
export const incidentBubbleAria = "Résoudre l'incident (cliquer pour bonus)";
export const sellZoneTitle = "Vente : glissez un animal ici (envoi à un autre zoo)";
export const worldMapTitle = "Carte du monde";
export const sellZoneShortLabel = "Vente";
export const restartButton = "Recommencer";
export const helpRestart = "Recommence une nouvelle partie sans bonus de prestige.";
export const questsTitle = "Objectifs du jour";
export const salesPanelAriaLabel = "Enchères et ventes";
export const salesPanelMySales = "Mes ventes";
export const salesPanelToRecover = "À récupérer";
export const salesPanelAuctions = "Enchères";
export const salesBtnAccept = "Accepter";
export const salesBtnReject = "Refuser";
export const salesBtnDeliver = "Récupérer";
export const salesBtnBid = "Enchérir";
export const salesPendingValidation = "En attente de validation";
export const salesValidationInMinutes = "Validation dans %s min";
export const salesBidInputAriaLabel = "Montant de l'enchère";
export const noFreeNursery = "Plus de place en nurserie";
export const noFreeReception = "Plus de place à l'accueil";
/** Auto-mode profile families (15). */
export const autoProfileFamilyLabel = {
1: "Conservateurs",
2: "Éleveurs",
3: "Commerçants",
4: "Expansionnistes",
5: "Scientifiques",
};
/**
* Auto-mode specialisation label by profile id (150). Keys are string numbers.
* @type {Record<string, string>}
*/
export const autoProfileSpecialisationLabel = (() => {
const out = {};
const familyNames = ["Conservateur", "Éleveur", "Commerçant", "Expansionniste", "Scientifique"];
for (let f = 0; f < 5; f++) {
for (let i = 1; i <= 10; i++) {
const id = f * 10 + i;
out[String(id)] = `${familyNames[f]} ${i}`;
}
}
return out;
})();
/** Auto-mode priorities text by profile id (150). Keys are string numbers. */
export const autoProfilePrioritiesLabel = (() => {
const out = {};
for (let id = 1; id <= 50; id++) out[String(id)] = `Priorités profil ${id}`;
return out;
})();
/** Auto-mode risks text by profile id (150). Keys are string numbers. */
export const autoProfileRisksLabel = (() => {
const out = {};
for (let id = 1; id <= 50; id++) out[String(id)] = `Risques profil ${id}`;
return out;
})();
export const autoProfilePickerTitle = "Choisir le profil du mode auto";
export const autoProfilePickerFamilyStep = "Choisir une famille";
export const autoProfilePickerSpecialisationStep = "Choisir une spécialisation";
export const autoProfileActivate = "Activer avec ce profil";
export const autoProfileCancel = "Annuler";

39
web/js/time-weather.js Normal file
View File

@@ -0,0 +1,39 @@
import { GameConfig } from "./config.js";
/**
* @param {import("./types.js").GameState} state
* @param {number} dtWallSeconds
*/
export function tickTime(state, dtWallSeconds) {
const dayLength = GameConfig.Time.DayLengthSeconds;
const phase = (state.timeOfDay ?? 6) + (dtWallSeconds * 24) / dayLength;
state.timeOfDay = phase >= 24 ? phase - 24 : phase;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {string}
*/
export function tickWeather(state, nowUnix) {
const last = state.lastWeatherChangeAt ?? 0;
if (nowUnix - last < GameConfig.Weather.ChangeIntervalSeconds) return state.weather ?? "sun";
state.lastWeatherChangeAt = nowUnix;
const r = Math.random();
if (r < GameConfig.Weather.RainChance) state.weather = "rain";
else if (r < GameConfig.Weather.RainChance + GameConfig.Weather.CloudyChance) state.weather = "cloudy";
else state.weather = "sun";
return state.weather;
}
/**
* @param {number} timeOfDay 0..24
* @returns {{ phase: string, intensity: number }}
*/
export function getTimePhase(timeOfDay) {
const t = timeOfDay % 24;
if (t >= 5 && t < 8) return { phase: "dawn", intensity: (t - 5) / 3 };
if (t >= 8 && t < 18) return { phase: "day", intensity: 1 };
if (t >= 18 && t < 21) return { phase: "dusk", intensity: (21 - t) / 3 };
return { phase: "night", intensity: 1 };
}

132
web/js/trade.js Normal file
View File

@@ -0,0 +1,132 @@
import { LootTables } from "./loot-tables.js";
import { getIncomeMultiplier } from "./mutation-rules.js";
import { getSellValue } from "./economy.js";
import { cellKey } from "./grid-utils.js";
import { getBlockKeysFromCell } from "./placement.js";
import { GameConfig } from "./config.js";
import { getReproductionScore } from "./reproduction.js";
/**
* Put a mature baby from nursery on sale (phase 10). Removes it from pendingBabies and clears the nursery cell.
* @param {import("./types.js").GameState} state
* @param {string} nurseryCellKey
* @returns {[boolean, string]} [ok, listingId or reason]
*/
export function addMatureBabyToSale(state, nurseryCellKey) {
const now = Math.floor(Date.now() / 1000);
const pendingBabies = state.pendingBabies ?? [];
const idx = pendingBabies.findIndex(
(p) => p.nurseryCellKey === nurseryCellKey && now >= p.readyAt
);
if (idx < 0) {
const first = pendingBabies.find((p) => p.nurseryCellKey === nurseryCellKey);
return [false, first ? "BabyNotMature" : "NoBabyInNursery"];
}
const baby = pendingBabies[idx];
state.pendingBabies = pendingBabies.filter((_, i) => i !== idx);
const cell = state.grid.cells[nurseryCellKey];
if (cell && cell.kind === "nursery") cell.tokenId = undefined;
state.saleListings = state.saleListings ?? [];
const duration = GameConfig.Sale?.ListingDurationSeconds ?? 3600;
const price = GameConfig.Sale?.DefaultPrice ?? 50;
const listingId = `sale_${state.nextTokenId}`;
state.nextTokenId += 1;
state.saleListings.push({
id: listingId,
zooId: state.myZooId ?? "player",
animalId: baby.animalId,
isBaby: true,
price,
endAt: now + duration,
reproductionScoreAtSale: getReproductionScore(state),
});
state.lastEvolutionAt = now;
return [true, listingId];
}
/**
* Put a ready reception animal on sale (phase 10). Removes it from receptionAnimals and clears the reception cell.
* @param {import("./types.js").GameState} state
* @param {string} receptionCellKey
* @returns {[boolean, string]} [ok, listingId or reason]
*/
export function addReceptionAnimalToSale(state, receptionCellKey) {
const now = Math.floor(Date.now() / 1000);
const receptionAnimals = state.receptionAnimals ?? [];
const idx = receptionAnimals.findIndex(
(r) => r.receptionCellKey === receptionCellKey && now >= r.readyAt
);
if (idx < 0) {
const first = receptionAnimals.find((r) => r.receptionCellKey === receptionCellKey);
return [false, first ? "AnimalNotReady" : "NoAnimalInReception"];
}
const rec = receptionAnimals[idx];
state.receptionAnimals = receptionAnimals.filter((_, i) => i !== idx);
state.saleListings = state.saleListings ?? [];
const duration = GameConfig.Sale?.ListingDurationSeconds ?? 3600;
const price = GameConfig.Sale?.DefaultPrice ?? 50;
const listingId = `sale_${state.nextTokenId}`;
state.nextTokenId += 1;
state.saleListings.push({
id: listingId,
zooId: state.myZooId ?? "player",
animalId: rec.animalId,
isBaby: false,
price,
endAt: now + duration,
reproductionScoreAtSale: rec.reproductionScoreAtSale ?? getReproductionScore(state),
});
state.lastEvolutionAt = now;
return [true, listingId];
}
/**
* Remove expired sale listings. If listing was a baby (isBaby), increment deathCountRecent (bébé invendu meurt).
* Call from game loop each tick.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function tickSaleListings(state, nowUnix) {
const listings = state.saleListings ?? [];
const kept = [];
let babyDeaths = 0;
for (const listing of listings) {
if (nowUnix < listing.endAt) {
kept.push(listing);
} else if (listing.isBaby) {
babyDeaths += 1;
}
}
state.saleListings = kept;
if (babyDeaths > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babyDeaths;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, number | string]}
*/
export function sellAnimalToNpc(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "animal") return [false, "NoAnimal"];
const animalDef = LootTables.Animals[cell.id];
if (animalDef === null || animalDef === undefined) throw new Error("TradeService: unknown animal");
const blockKeys = getBlockKeysFromCell(state, x, y);
const originKey = blockKeys[0];
const originCell = state.grid.cells[originKey];
if (originCell === null || originCell === undefined || originCell.kind !== "animal") return [false, "NoAnimal"];
const mutationMultiplier = getIncomeMultiplier(originCell.mutation);
const sellValue = getSellValue(
animalDef.baseIncomePerSecond,
originCell.level,
mutationMultiplier,
animalDef.sellFactor
);
for (const k of blockKeys) delete state.grid.cells[k];
state.coins += sellValue;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);
if (state.stats) state.stats.animalsSold = (state.stats.animalsSold ?? 0) + 1;
return [true, sellValue];
}

82
web/js/types.js Normal file
View File

@@ -0,0 +1,82 @@
/**
* @typedef {{ kind: "egg", eggType: string, tokenId: number, hatchAt: number, seed: number }} EggCell
* @typedef {{ kind: "animal", id: string, mutation: string, level: number, placedAt: number, lastVisitedAt?: number, lastFedAt?: number, originKey?: string, cellsWide?: number, cellsHigh?: number, fromOtherZoo?: boolean }} AnimalCell
* @typedef {{ kind: "school", level: number }} SchoolCell
* @typedef {{ kind: "nursery", tokenId?: number, level?: number }} NurseryCell
* @typedef {{ kind: "souvenirShop", level: number }} SouvenirShopCell
* @typedef {{ kind: "research", level: number }} ResearchCell
* @typedef {{ kind: "billeterie", level: number }} BilleterieCell
* @typedef {{ kind: "food", level: number }} FoodCell
* @typedef {{ kind: "reception", level: number }} ReceptionCell
* @typedef {{ kind: "biomeChangeColor", level: number }} BiomeChangeColorCell
* @typedef {{ kind: "biomeChangeTemp", level: number }} BiomeChangeTempCell
* @typedef {EggCell | AnimalCell | SchoolCell | NurseryCell | SouvenirShopCell | ResearchCell | BilleterieCell | FoodCell | ReceptionCell | BiomeChangeColorCell | BiomeChangeTempCell} Cell
*
* @typedef {{ id: string, animalId: string, nurseryCellKey: string, readyAt: number, fromOtherZoo?: boolean }} PendingBaby
* @typedef {{ id: string, animalId: string, receptionCellKey: string, readyAt: number, originZooId?: string, reproductionScoreAtSale?: number }} ReceptionAnimal
* @typedef {{ id: string, zooId: string, babyId?: string, animalId?: string, isBaby: boolean, price: number, endAt: number, reproductionScoreAtSale?: number, serverId?: string, bestBidAmount?: number, bestBidderZooId?: string, status?: "active"|"sold"|"expired"|"rejected"|"validated", validatedAt?: number | null }} SaleListing
*
* @typedef {{ arrivedAt: number, incidentType?: "thirst"|"bin"|"bench"|"animalFar"|"photo", incidentSince?: number }} VisitorEntry
*
* @typedef {{ id: string, descriptionKey: string, target: number, current: number, reward: number, done: boolean }} Quest
*
* @typedef {{ coins: number, plotLevel: number, conveyorLevel: number, truckLevel: number, profile: "fast"|"slow"|"balanced", lastTickAt: number }} BotState
* @typedef {{ id: string, name: string, x: number, y: number, animalWeights: Record<string, number>, botState?: BotState }} WorldZooEntry
*
* @typedef {{
* version: number,
* specVersion?: number,
* coins: number,
* conveyorLevel: number,
* plotLevel: number,
* truckLevel?: number,
* grid: { width: number, height: number, cells: Record<string, Cell> },
* pendingEggTokens: Array<{ tokenId: number, eggType: string, boughtAt: number }>,
* nextTokenId: number,
* conveyorOffers: Array<{ eggType: string, price: number, zooId?: string }>,
* lastOfferRefreshAt: number,
* worldZoos?: Array<WorldZooEntry>,
* truckSale?: { toZooId: string, startAt: number },
* eggPurchaseTruck?: { eggType: string, fromZooId: string, toZooId: string, startAt: number },
* worldTruckSales?: Array<{ fromZooId: string, toZooId: string, startAt: number }>,
* lastEvolutionAt?: number,
* laboratoryOffer?: { eggType: string, price: number, endAt: number } | null,
* prestigeLevel?: number,
* timeOfDay?: number,
* weather?: string,
* lastWeatherChangeAt?: number,
* quests?: Quest[],
* lastQuestDay?: string,
* stats?: { eggsPlaced: number, animalsSold: number, conveyorUpgrades: number, plotUpgrades: number, truckUpgrades?: number },
* mapZoom?: number,
* mapPanX?: number,
* mapPanY?: number,
* worldMapLevel?: number,
* autoMode?: boolean,
* autoModeProfile?: "fast"|"slow"|"balanced",
* autoModeProfileId?: number,
* autoProfilePickerOpen?: boolean,
* autoProfilePickerFamily?: number,
* lastPlayerAutoTickAt?: number,
* myZooId?: string,
* playerName?: string,
* playerX?: number,
* playerY?: number,
* researchPoints?: number,
* pendingBabies?: PendingBaby[],
* receptionAnimals?: ReceptionAnimal[],
* saleListings?: SaleListing[],
* salesFromApi?: { asSeller: Array<{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: string, status: string, best_bid_amount?: number, best_bidder_zoo_id?: string, sold_at?: string, validated_at?: string | null, reproduction_score_at_sale?: number }>, asBuyerUndelivered: Array<{ id: string, animal_id: string, is_baby: boolean, initial_price: number, status?: string, validated_at?: string | null, reproduction_score_at_sale?: number }>, active: Array<{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, end_at: string, best_bid_amount?: number }> },
* deathCountRecent?: number,
* birthCount?: number,
* feedingRate?: number,
* reproductionScore?: number,
* attractivityScore?: number,
* attractivityScore?: number,
* reproductionTimers?: Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>,
* visitorArrivals?: VisitorEntry[],
* attractivityBonusFromIncidents?: number,
* }} GameState
*/
export default {};

1606
web/js/ui.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
import { LootTables } from "./loot-tables.js";
import { getSellValue } from "./economy.js";
import { getIncomeMultiplier } from "./mutation-rules.js";
const CELL_PITCH = 52;
const GRID_PADDING = 6;
const CELL_CENTER_OFFSET = 24;
/**
* Center of a cell in layer pixel coordinates (1-based x, y).
* @param {number} x 1-based column
* @param {number} y 1-based row
* @returns {{ cx: number, cy: number }}
*/
function cellCenterPx(x, y) {
const cx = GRID_PADDING + (x - 1) * CELL_PITCH + CELL_CENTER_OFFSET;
const cy = GRID_PADDING + (y - 1) * CELL_PITCH + CELL_CENTER_OFFSET;
return { cx, cy };
}
/**
* Weighted center of the zoo by animal value (sell value). Visitors are attracted to expensive animals.
* @param {import("./types.js").GameState} state
* @param {number} gridWidth
* @param {number} gridHeight
* @returns {{ centerX: number, centerY: number }}
*/
export function getAttractionCenter(state, gridWidth, gridHeight) {
let sumW = 0;
let sumWx = 0;
let sumWy = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal") {
const def = LootTables.Animals[cell.id];
if (def !== null && def !== undefined) {
const mut = getIncomeMultiplier(cell.mutation ?? "none");
const w = getSellValue(def.baseIncomePerSecond, cell.level ?? 1, mut, def.sellFactor);
const [x, y] = key.split("_").map(Number);
const { cx, cy } = cellCenterPx(x, y);
sumW += w;
sumWx += w * cx;
sumWy += w * cy;
}
}
}
if (sumW <= 0) {
const cx = GRID_PADDING + (gridWidth * CELL_PITCH - 4) / 2;
const cy = GRID_PADDING + (gridHeight * CELL_PITCH - 4) / 2;
return { centerX: cx, centerY: cy };
}
return {
centerX: sumWx / sumW,
centerY: sumWy / sumW,
};
}
/**
* Cell key (1-based x, y) at the given pixel position in the grid layer.
* @param {number} px
* @param {number} py
* @param {number} gridWidth
* @param {number} gridHeight
* @returns {string} key "x_y" or empty if out of bounds
*/
export function getCellKeyFromPixelPosition(px, py, gridWidth, gridHeight) {
const x = 1 + Math.round((px - GRID_PADDING - CELL_CENTER_OFFSET) / CELL_PITCH);
const y = 1 + Math.round((py - GRID_PADDING - CELL_CENTER_OFFSET) / CELL_PITCH);
if (x < 1 || x > gridWidth || y < 1 || y > gridHeight) return "";
return `${x}_${y}`;
}
/**
* Unique position for visitor i: orbits around the attraction center with per-visitor phase, radius and speed.
* A second harmonic gives figure-8 / Lissajous-style paths so each visitor has a distinct walk.
* @param {{ i: number, n: number, t: number, centerX: number, centerY: number, gridWidth: number, gridHeight: number }} opts
* @returns {{ px: number, py: number }}
*/
export function getVisitorPosition(opts) {
const { i, n, t, centerX, centerY, gridWidth, gridHeight } = opts;
const phase1 = (i / Math.max(1, n)) * Math.PI * 2 + ((i * 17) % 100) * 0.01;
const phase2 = ((i * 13) % 100) * 0.063;
const radius1 = 28 + (i * 31) % 55;
const radius2 = 12 + (i * 11) % 18;
const speed1 = 0.15 + ((i * 7) % 50) * 0.008;
const speed2 = 0.08 + ((i * 19) % 40) * 0.006;
const angle1 = phase1 + t * speed1;
const angle2 = phase2 + t * speed2;
const px = centerX + radius1 * Math.cos(angle1) + radius2 * Math.cos(angle2 * 1.3);
const py = centerY + radius1 * Math.sin(angle1) + radius2 * Math.sin(angle2 * 0.9);
const minX = GRID_PADDING;
const minY = GRID_PADDING;
const maxX = GRID_PADDING + gridWidth * CELL_PITCH - 4 - 20;
const maxY = GRID_PADDING + gridHeight * CELL_PITCH - 4 - 20;
return {
px: Math.max(minX, Math.min(maxX, px)),
py: Math.max(minY, Math.min(maxY, py)),
};
}

View File

@@ -0,0 +1,86 @@
/**
* Visitor incidents (thirst, bin full, bench required, animal too far, want photo).
* Appear more often during wait phases; resolve by click for bonus, or timeout applies penalty.
*/
import { GameConfig } from "./config.js";
/** Incident type keys for i18n and display. */
export const INCIDENT_TYPES = ["thirst", "bin", "bench", "animalFar", "photo"];
/** Emoji per incident type for bubble display. */
export const INCIDENT_EMOJI = { thirst: "💧", bin: "🗑️", bench: "🪑", animalFar: "🦌", photo: "📷" };
/**
* True when player is in a wait phase (truck moving, sale pending validation, etc.).
* @param {import("./types.js").GameState} state
* @returns {boolean}
*/
export function isInWaitPhase(state) {
if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true;
if (state.truckSale && state.truckSale.startAt) return true;
const api = state.salesFromApi;
if (api && api.asBuyerUndelivered && api.asBuyerUndelivered.length > 0) {
const nowMs = Date.now();
for (const s of api.asBuyerUndelivered) {
const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0;
const pending = (s.status === "sold" || s.status === "validated") && validatedAtMs > nowMs;
if (pending) return true;
}
}
return false;
}
/**
* Spawn and expire incidents. Call after tickVisitorArrivals.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function tickVisitorIncidents(state, nowUnix) {
const arrivals = state.visitorArrivals ?? [];
const cfg = GameConfig.Visitor;
const baseChance = cfg?.IncidentChanceBase ?? 0.002;
const waitMult = cfg?.IncidentChanceWaitMultiplier ?? 4;
const timeoutSec = cfg?.IncidentTimeoutSeconds ?? 45;
const penalty = cfg?.IncidentUnresolvedAttractivityPenalty ?? 0.2;
const inWait = isInWaitPhase(state);
const chance = inWait ? baseChance * waitMult : baseChance;
const toRemove = [];
for (let i = 0; i < arrivals.length; i++) {
const v = arrivals[i];
if (v.incidentType !== null && v.incidentType !== undefined) {
if (nowUnix - (v.incidentSince ?? nowUnix) >= timeoutSec) {
state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) - penalty;
toRemove.push(i);
}
} else if (Math.random() < chance) {
v.incidentType = INCIDENT_TYPES[Math.floor(Math.random() * INCIDENT_TYPES.length)];
v.incidentSince = nowUnix;
}
}
for (let r = toRemove.length - 1; r >= 0; r--) {
arrivals.splice(toRemove[r], 1);
}
}
/**
* Resolve incident for visitor at index: clear incident, add coins and attractivity bonus. Mutates state.
* @param {import("./types.js").GameState} state
* @param {number} visitorIndex
* @returns {boolean} true if an incident was resolved
*/
export function resolveIncident(state, visitorIndex) {
const arrivals = state.visitorArrivals ?? [];
const v = arrivals[visitorIndex];
if (!v || (v.incidentType === null || v.incidentType === undefined)) return false;
const cfg = GameConfig.Visitor;
const coinBonus = cfg?.IncidentResolveCoinBonus ?? 8;
const attractivityBonus = cfg?.IncidentResolveAttractivityBonus ?? 0.15;
state.coins += coinBonus;
state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) + attractivityBonus;
if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + coinBonus;
delete v.incidentType;
delete v.incidentSince;
return true;
}

33
web/js/weighted-random.js Normal file
View File

@@ -0,0 +1,33 @@
/**
* Seeded RNG (mulberry32) for deterministic hatch results.
* @param {number} seed
* @returns {() => number}
*/
export function createSeededRng(seed) {
let state = seed >>> 0;
return function next() {
state = (state + 0x6d2b79f5) >>> 0; // 32-bit
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* @param {() => number} rng
* @param {Array<{ id: string, weight: number }>} entries
* @returns {string}
*/
export function pickId(rng, entries) {
let total = 0;
for (const e of entries) total += e.weight;
if (total <= 0) throw new Error("WeightedRandom: non-positive total weight");
const roll = rng() * total;
let cumulative = 0;
for (const e of entries) {
cumulative += e.weight;
if (roll < cumulative) return e.id;
}
return entries[entries.length - 1].id;
}

89
web/js/world-map.js Normal file
View File

@@ -0,0 +1,89 @@
import { GameConfig } from "./config.js";
import { LootTables, getColorNames } from "./loot-tables.js";
const truckMs = () => (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500;
const npcIntervalMs = () => (GameConfig.WorldMap && GameConfig.WorldMap.NpcTruckIntervalMs) || 8000;
/**
* Remove expired truck sales (player and NPC).
* @param {import("./types.js").GameState} state
* @param {number} now
*/
export function pruneTruckSales(state, now) {
const ms = truckMs();
if (state.truckSale && state.truckSale.startAt && now - state.truckSale.startAt >= ms) {
delete state.truckSale;
}
if (state.worldTruckSales && state.worldTruckSales.length > 0) {
state.worldTruckSales = state.worldTruckSales.filter(
(t) => t.startAt && now - t.startAt < ms
);
}
}
/**
* Add one NPC truck sale between two random zoos (can include player as destination).
* @param {import("./types.js").GameState} state
* @param {number} now
*/
export function addNpcTruckSale(state, now) {
const zoos = state.worldZoos ?? [];
if (zoos.length < 2) return;
const fromIdx = Math.floor(Math.random() * zoos.length);
let toIdx = Math.floor(Math.random() * zoos.length);
if (toIdx === fromIdx) toIdx = (toIdx + 1) % zoos.length;
const fromZooId = zoos[fromIdx].id;
const toZooId = zoos[toIdx].id;
if (!state.worldTruckSales) state.worldTruckSales = [];
state.worldTruckSales.push({ fromZooId, toZooId, startAt: now });
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function tickWorldMap(state, nowUnix) {
const nowMs = nowUnix * 1000;
pruneTruckSales(state, nowMs);
}
/**
* Should we add an NPC truck this tick? Call from game loop with a lastNpcTruckAt tracker.
* @param {number} nowMs
* @param {number} lastNpcTruckAt
* @returns {boolean}
*/
export function shouldAddNpcTruck(nowMs, lastNpcTruckAt) {
return nowMs - lastNpcTruckAt >= npcIntervalMs();
}
/**
* Laboratory offer duration in seconds.
*/
const LAB_OFFER_DURATION = 45;
/**
* Chance per refresh (0-1) that the lab has a special offer.
*/
const LAB_OFFER_CHANCE = 0.25;
/**
* Refresh or expire laboratory offer. Special egg = one of Basic, Ocean, Mountain with a price modifier.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @param {() => number} rng
*/
export function tickLaboratory(state, nowUnix, rng = Math.random) {
const lab = state.laboratoryOffer;
if (lab && nowUnix >= lab.endAt) {
state.laboratoryOffer = null;
}
if (!lab && rng() < LAB_OFFER_CHANCE) {
const types = getColorNames();
const eggType = types[Math.floor(rng() * types.length)];
const eggDef = LootTables.EggTypes[eggType];
const base = eggDef ? eggDef.price : 50;
const price = Math.floor(base * (0.8 + rng() * 0.4));
state.laboratoryOffer = { eggType, price, endAt: nowUnix + LAB_OFFER_DURATION };
}
}

36
web/js/zoo-nursery.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* Nursery helpers: ordered keys and first free cell.
*/
/**
* Ordered nursery cell keys (by row then column) for consistent token assignment.
* @param {import("./types.js").GameState} state
* @returns {string[]}
*/
export function getNurseryCellKeysOrdered(state) {
const keys = [];
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell && cell.kind === "nursery") keys.push(key);
}
keys.sort((a, b) => {
const [ax, ay] = a.split("_").map(Number);
const [bx, by] = b.split("_").map(Number);
return ay !== by ? ay - by : ax - bx;
});
return keys;
}
/**
* First nursery cell key that has no token and no pending baby. Returns null if none.
* @param {import("./types.js").GameState} state
* @returns {string | null}
*/
export function getFreeNurseryCellKey(state) {
const keys = getNurseryCellKeysOrdered(state);
const usedKeys = new Set((state.pendingBabies ?? []).map((p) => p.nurseryCellKey));
for (const key of keys) {
const cell = state.grid.cells[key];
if (cell && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined) && !usedKeys.has(key)) return key;
}
return null;
}

359
web/js/zoo.js Normal file
View 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];
}