/** * 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 truck (egg or sale) is in progress. * @param {import("./types.js").GameState} state * @returns {boolean} */ function hasTruckWait(state) { if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true; if (state.truckSale && state.truckSale.startAt) return true; return false; } /** * True when there are API sales as buyer undelivered with pending validation. * @param {import("./types.js").GameState} state * @returns {boolean} */ function hasApiUndeliveredWait(state) { const api = state.salesFromApi; if (!api || !api.asBuyerUndelivered || api.asBuyerUndelivered.length === 0) return false; 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; } /** * 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) { return hasTruckWait(state) || hasApiUndeliveredWait(state); } /** * Expire timed-out incidents and apply penalty. Returns indices to remove from arrivals. * @param {import("./types.js").VisitorArrival[]} arrivals * @param {{ nowUnix: number, timeoutSec: number, penalty: number, stateRef: { attractivityBonusFromIncidents: number } }} opts * @returns {number[]} */ function expireIncidents(arrivals, opts) { const { nowUnix, timeoutSec, penalty, stateRef } = opts; 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) { stateRef.attractivityBonusFromIncidents = (stateRef.attractivityBonusFromIncidents ?? 0) - penalty; toRemove.push(i); } } } return toRemove; } /** * Spawn new incidents on visitors without one (by chance). * @param {import("./types.js").VisitorArrival[]} arrivals * @param {number} chance * @param {number} nowUnix */ function spawnIncidents(arrivals, chance, nowUnix) { for (let i = 0; i < arrivals.length; i++) { const v = arrivals[i]; const hasNoIncident = v.incidentType === null || v.incidentType === undefined; if (hasNoIncident && Math.random() < chance) { v.incidentType = INCIDENT_TYPES[Math.floor(Math.random() * INCIDENT_TYPES.length)]; v.incidentSince = nowUnix; } } } /** * @returns {{ baseChance: number, waitMult: number, timeoutSec: number, penalty: number }} */ function getIncidentConfig() { const cfg = GameConfig.Visitor; return { baseChance: cfg?.IncidentChanceBase ?? 0.002, waitMult: cfg?.IncidentChanceWaitMultiplier ?? 4, timeoutSec: cfg?.IncidentTimeoutSeconds ?? 45, penalty: cfg?.IncidentUnresolvedAttractivityPenalty ?? 0.2, }; } /** * 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 { baseChance, waitMult, timeoutSec, penalty } = getIncidentConfig(); const inWait = isInWaitPhase(state); const chance = inWait ? baseChance * waitMult : baseChance; const toRemove = expireIncidents(arrivals, { nowUnix, timeoutSec, penalty, stateRef: state }); for (let r = toRemove.length - 1; r >= 0; r--) { arrivals.splice(toRemove[r], 1); } spawnIncidents(arrivals, chance, nowUnix); } /** * Apply resolve bonus (coins + attractivity) to state. Mutates state and v. * @param {import("./types.js").GameState} state * @param {import("./types.js").VisitorArrival} v */ function applyResolveBonus(state, v) { 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; } /** * 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; applyResolveBonus(state, v); return true; }