Simulation
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Last updated: October 25, 2026
Zoo Simulator -- Simulation & Visitors Subsystem
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Daily simulation phases, visitor generation, morale mechanics, theme/design bonuses, random events, and the day summary system. All formulas extracted from Java source code.
Daily Simulation Overview
Source:
The simulation runs a construction pre-phase plus 7 phases in strict order when the player uses the "advance day" command. Each phase delegates to a specialized service.
Advance-day must never go silent (fb 8762). The spoken daily summary (births, deaths, revenue) is rendered as APLA
Speechitems and is NEVER gated by the SFX/music audio flags — those control sound EFFECTS and background music only (see § "Two renderers — keep them in lock-step" and the SFX decouple). The "advance day goes completely silent" reports were NOT an audio gate: on heavily-played accounts the bloated in-blobtransactionHistory(up to 200 entries / ~43KB) made advance-day's load-mutate-save round-trip exceed Alexa's ~8s response window (or throw a Redis save exception), so the fully-built summary never reached the device. Fixed by trimming the in-blob window to 25 and moving the durable ledger tozoo_simulator.zoo_transactions(seeeconomy.md§ Transaction Ledger Storage). Regression shield: scenario_test "advance day" (fb 8762) asserts the summary renders.
Zero-animal advance-day reframes; it does not run a dead tick (RVF group 164 finding F1). guards on
.countAnimals(account) == 0: a zoo with no animals would draw zero visitors and earn zero revenue while still paying habitat upkeep, so instead of simulating that dead tick the handler reframes the player toward buying their first animal (saybuy animal penguin) and returns without callingsimulateDay. Once the zoo owns at least one animal, advance-day runs the full 7-phase simulation below as normal.
Advance-day SPEAKS A HEADLINE; the detail is PULLED, not pushed. A large zoo's full summary sums past the APLA speech budget (measured live:
budget=2508, overruns at 2541 / 2555 / 2697, worst 3226) and was cut off mid-sentence — 65 truncations in 7 days, concentrated in just 5 large-zoo players. Every section already caps itself; the SUM is what overruns, which is why the answer is a different SHAPE rather than another cap.is UNCHANGED and still builds the whole summary — every section keeps its own cap and its own phrasing. captures that full text into
ZooAccount.lastDaySummary, then rolls the response builder back to the pre-summary mark and calls , which tells the day as a story: the day's first event in its first sentence, without its category tag, and the first visitor highlight lead ("Rain swept through the city today. Maya, a school teacher, was thrilled by your white tiger."), then the numbers, each rounded the way a person says it (PurchaseVoiceFormatter.spokenRounded: exact under a thousand, to the hundred under ten thousand, to the thousand under a million, compact past that, with "about" on a rounded figure): "Visitors 79. About one thousand eight hundred Zoo Bucks in revenue, four hundred forty in expenses, balance about three thousand eight hundred." A day with no event and no highlight leads with the numbers. Then a line each for deaths, births and completed goals when they are non-zero. Rounded numbers keep every clause a few words long, so a billionaire's turn never trims a clause down to a word with no number after it. A litter lost because a parent left the zoo before the birth is spoken in the headline too, in full ("Leo's lion litter was lost, because its mother is no longer in your zoo."): it is a loss the player usually caused without knowing, often by a sale, so it is not left in the pulled detail (animals.md§ Breeding Flow).
repeaton Alexa replays what was actually spoken this turn, not the full stored summary. replays the cached Alexa response — the headline, plus the next-goal teaser and any monetization pitch that fired this cycle (see below) — neverlastDaySummaryitself. The web/UI path is the one that replays the full stored detail; it has no Alexa response cache and falls back tolastDaySummaryon repeat.tell me moreis the only path to the full detail on Alexa.The detail is then reachable with
tell me more, which the headline names as "Say tell me more for the full day, or advance day again";review current day,the full day,the rest of the day,review dayandwhat happenedreach it too (ZOO_REVIEW_DAY-> ), which speaks one grouping oflastDaySummaryper turn and rotates, wrapping at the end so the player can keep circling the same day. Advance-day resetsZooAccount.dayReviewCursorto zero, and that reset is what scopes the review to the most recent day.Every part ends on the next action. A part with more after it ends "Say review current day for the next part, or advance day to open tomorrow's gates." The last part ends on what tomorrow needs first (): sick animals with no veterinarian come first, "Tomorrow starts with two sick animals and no veterinarian, so say hire veterinarian, then advance day.", with hire veterinarian offered; otherwise "That is the whole day. Say advance day to open tomorrow's gates." A review that ended on its last row was where players most often left.
There is no second copy of the day text. The groupings are DERIVED from
lastDaySummaryby splitting on the spoken row separator the summary already emits, so the boundaries are a pure function of the stored text — deterministic, no clock, no RNG, and nothing to keep in sync. Rows that render as silence (a stray separator, a bare sound-cue marker) are dropped before grouping, so a promised part always speaks something.The grouping boundary is the SPEECH BUDGET, not a row count. is 1200: rows are packed whole until the next one would exceed it. A row count cannot bound speech — eight long rows overrun exactly as the whole summary did — so bounding the thing that actually truncates is the point. A row longer than the entire budget still gets a grouping of its own, or the rotation could never advance past it. In practice a normal day lands in one pull and only the large zoo that was truncating splits, which is roughly the eight-responses-in-a-rotation shape the owner asked for.
The day's gated monetization pitch (at most one per day — the subscribe nag or a contextual upsell) still fires, and is still spoken, on the ADVANCE turn. The gating (
upsellFiredThisCycle, the seven-day subscribe cooldown) and the funnel-impression write happen whilebuildSummaryruns, exactly as before — a pitch built but rolled back off the response with the rest of the detail would burn that cooldown and log an impression for words nobody heard.buildSummaryreturns the character span of whichever section produced the pitch, if any;advanceDaypulls that exact text back out before the rollback and speaks it right after the headline. A pitch that fires is therefore always heard THIS turn, never deferred intotell me more, and dropped only by the turn budget below. Re-measure conversion once this ships; the mechanism did not change, only the summary's shape around it.The advance-day turn reads in about twenty seconds.
ZooAdvanceDaySpeech.DAY_TURN_SECONDSis 20. When the headline, the pitch and the next-goal teaser together run longer,fitSoftBudgetrebuilds the turn in this order: the day's story sentences that fit beside the numbers, the day's numbers (: visitors, money in and out, the balance), a lost litter, the other sentences that carry the visitors, the earnings or an animal's need, thenZooAdvanceDaySpeech.TELL_ME_MORE_LEAD, then the pitch when it still fits. The numbers are always heard: a long event or visitor moment gives up its own sentences first, one at a time, and a sentence may end on an exclamation. Room for the tell me more line is held back while the day's lines are added, so it is never the line that is cut. The pitch is taken out of the day's lines first, so it is never spoken ahead of the day.
Advance-day closes on a next-goal teaser. When the tick completed no Zoo Goal, builds a one-line next-goal teaser () inside the captured summary span, then re-speaks it after the headline (same reasoning as the monetization pitch above -- a teaser only helps if the player actually hears it), so each tick still advances something legible and goal progress is not lost from the spoken turn. When the tick DID complete a goal, the headline's goal line already speaks the completed count and the completion announcement carries the payoff. See quests.md § Discovery, Onboarding & Endgame Surfacing.
Pre-Phase: Construction -> processConstruction() [added 2026-04-04]
-> Decrements constructionDaysLeft on CONSTRUCTION grid squares
-> Converts completed squares to HABITAT/ATTRACTION
-> Unblocks squares, clears construction fields
-> Returns completedStructures list for day summary
Phase 1: Events -> .generateDailyEvents()
Phase 2: Health -> .autoTreatWithVets() [runs FIRST - prevents same-day illness progression for treatable animals]
-> .performDailyHealthCheck()
-> Subscriber weekly sighting (every 7 days for subscribers: +2,000 ZB bonus revenue, subscriberSightingGranted flag set on lastSubscriberSightingDate)
Phase 3: Breeding -> .processDailyBreeding()
Training -> .processDailyTraining()
Enrichment -> .processDailyEnrichment()
Phase 4: Visitors -> Calculate visitor count
-> .generateVisitors()
-> .simulateVisitorJourney() (per visitor)
Phase 5: Revenue -> .calculateRevenue()
-> Apply subscriber bonus (25%)
-> .addZooBucks()
Phase 6: Expenses -> .calculateDailyUpkeep()
-> .calculateDailyStaffCost()
-> Apply event expenses
-> Debt mechanics
Phase 7: Cleanup -> .removeExpiredPromotions()
-> .calculateAllScores()
Phase 1: Random Events
Source:
Event Probabilities (Per Day)
| Event | Chance | Effect |
|---|---|---|
| Weather | 15% | Rain (-40% visitors) OR Sunny (+30% visitors), 50/50 |
| Equipment Breakdown | 10% | If no maintenance staff: 300-1,500 repair cost. If staff: prevented |
| VIP Visit | 8% | +5,000 Zoo Bucks bonus revenue (requires 3+ animals) |
| Zoo Inspection | 5% | Pass (score >= 400 AND welfare >= 50) or fail (500-3,000 penalty) (requires 3+ animals) |
| Animal Escape | 3% | 300-2,000 recovery cost + 20% visitor reduction |
Event Constants
RAIN_VISITOR_REDUCTION = 0.4 (visitors * 0.6)
SUNNY_VISITOR_BOOST = 1.3 (visitors * 1.3)
ESCAPE_RECOVERY_COST = 2000
VIP_BONUS_REVENUE = 5000
BREAKDOWN_REPAIR_COST = 1500
INSPECTION_PENALTY = 3000
MAX_PENALTY_PERCENT = 0.25 (25% of current balance cap)
Event Cost Scaling
All event costs are capped at 25% of the zoo's current balance to prevent bankruptcy:
maxAllowedCost = max(300, currentBalance * 0.25)
actualCost = min(baseCost, maxAllowedCost)
Weather Events
Source:
Mutually exclusive -- either rain or sunny:
if random < 0.15:
if random < 0.5: // Rain
visitorMultiplier = 0.6
else: // Sunny
visitorMultiplier = 1.3
Animal Escape
Source:
- Selects a random animal from all animals in the zoo
- Escape cost capped at min(2000, max(300, balance * 0.25))
- Multiplies the visitor multiplier by 0.8 (stacks with weather)
Zoo Inspection
Source:
- Only triggers if zoo has >= 3 animals
- Pass condition:
zooScore >= 400 AND animalWelfareScore >= 50 - Fail penalty:
min(min(animalCount * 300, 3000), max(500, balance * 0.25))
VIP Visit
- Only triggers if zoo has >= 3 animals
- VIP types: Celebrity, Wealthy Philanthropist, Wildlife Documentary Crew, School Field Trip
- Always adds 5,000 Zoo Bucks bonus revenue
Equipment Breakdown
Source:
- Only triggers if zoo has >= 1 zone
- If maintenance staff > 0: prevented entirely
- If no maintenance staff:
min(min(zoneCount * 500, 1500), max(300, balance * 0.25))
Event Revenue Application
Source:
Event bonus revenue (VIP visits) is added directly to zooBucks after the visitor simulation. Event expenses are included in the total expenses calculation in Phase 6.
Phase 4: Visitor Generation
Visitor Count Formula
Source:
if animalCount == 0: return 0 // No animals = no visitors
baseVisitors = 20 + random(0, 30) // 20-50
totalVisitors = baseVisitors
+ (animalCount * 10)
+ (premiumAnimalCount * 30) // / fb 1110-1115 — premium-tier appeal bonus
+ (attractionCount * 5)
// VIP decoration bonus (subscriber feature, queue -era):
// +15% per habitat with a VIP decoration toggled on
if vipHabitatCount > 0:
vipMultiplier = 1.0 + (vipHabitatCount * 0.15)
totalVisitors = round(totalVisitors * vipMultiplier)
totalVisitors = round(totalVisitors * eventVisitorMultiplier)
After event multiplier, apply the active-promotion visitor multiplier (fb 8525). A create promotion activates a 1.5x visitor multiplier (); the daily sim now applies it to the visitor COUNT, matching the promotion's promise "Visitors will increase by 50%". Before this fix the multiplier existed but was never applied, so attendance stayed flat after starting a promotion:
promotionVisitorMultiplier = zooFinancialService.getVisitorMultiplier(account) // max over active promotions, 1.0 if none
if promotionVisitorMultiplier > 1.0:
visitorCount = round(visitorCount * promotionVisitorMultiplier)
Then apply debt penalty:
if wasInDebt:
debtPenalty = min(daysInDebt * 0.20, 0.60)
visitorCount = round(visitorCount * (1.0 - debtPenalty))
Visitor Properties
Source:
| Property | Range | Notes |
|---|---|---|
| spendingCapacity | 5-20 | 5 + random(0, 15) Zoo Bucks |
| morale | 50-70 + promotion bonus | 50 + random(0, 20) base, plus max promotion moraleBonus, capped at 100 |
Visitor Journey Simulation
Source:
Each visitor's journey is simulated individually:
Step 1: Visit Habitats
habitatsToVisit = 2 + random(0, 3) // 2-5 habitats
actualVisits = min(habitatsToVisit, availableHabitats)
For each habitat visited:
- Pick random habitat with animals
- Pick random animal in that habitat
- Update visitor morale based on animal appeal and habitat quality
- Apply zone bonuses (theme coherence + design quality) to morale gain
Step 2: Calculate Base Ticket Revenue
After habitat visits, calculateVisitorSpending() computes the base ticket revenue using the morale-based spending multiplier.
Step 3: Visit Attractions
For each zone with attractions:
visitChance = 0.5 + (morale / 200.0) // 50% to 100% chance
if random < visitChance:
attractionsToVisit = random < 0.6 ? 1 : 2
// Visit random attractions, generate revenue per visit
// Apply zone's combined attraction bonus
Step 4: Apply Promotion Spending Multiplier
if activePromotions exist:
maxSpendingMultiplier = max(promotion.spendingMultiplier for all active)
bonusSpending = round(currentSpent * (maxSpendingMultiplier - 1.0))
totalSpent += bonusSpending
Morale Mechanics
Morale Update from Exhibit Viewing
Source:
animalAppeal = animal.visitorAppeal // 0+
appealBonus = min(20, animalAppeal / 50) // 0-20 points
habitatQuality = habitat.qualityRating // 0-100
qualityMultiplier = 0.5 + (habitatQuality / 100.0) // 0.5-1.5
moraleChange = round(appealBonus * qualityMultiplier)
newMorale = clamp(0, 100, currentMorale + moraleChange)
Zone Theme Coherence Bonus
Two different theme systems. This Zone Theme Coherence Bonus rewards a zone whose animals match its
ZoneThemewith extra visitor morale (revenue channel) and has always been soft. It is distinct from the Habitat Theme Bonus (animals.md), which rewards placing an animal in a matching habitat type with a +10 happiness lift (welfare channel). Neither blocks placement — habitat-type compatibility stopped being a hard gate in .
Source:
themeCoherence = themedAnimals / totalAnimals // 0.0 to 1.0
bonus = 1.0 + (themeCoherence * 0.5) // 1.0x to 1.5x
// Small bonus for attractions in zone
if attractionCount > 0:
bonus += min(0.1, attractionCount * 0.02)
return min(1.5, bonus)
Theme-Species Mappings:
| Zone Theme | Matching Species |
|---|---|
| AFRICA | LION, ELEPHANT, GIRAFFE, ZEBRA, CROCODILE |
| NORTH_AMERICA | BEAR, EAGLE |
| SOUTH_AMERICA | PARROT, MONKEY |
| ASIA | TIGER, PANDA, ELEPHANT |
| ARCTIC | POLAR_BEAR, BEAR, PENGUIN, SEAL |
| AUSTRALIA | KANGAROO, SNAKE |
| AQUARIUM | DOLPHIN, SHARK, SEAL, OTTER, PENGUIN, TURTLE |
| RAINFOREST | PARROT, SNAKE, TURTLE |
| DESERT | SNAKE, EAGLE |
| EUROPE | WOLF, BEAR, DEER, OWL, FOX |
Zone Design Quality Bonus
Source:
multiplier = 0.8 + (designRating / 100.0 * 0.6) // 0.8x to 1.4x
| Design Rating | Multiplier |
|---|---|
| 0 | 0.80x |
| 25 | 0.95x |
| 50 (new zone default) | 1.10x |
| 75 | 1.25x |
| 95 (premium zone) | 1.37x |
| 100 | 1.40x |
Combined Zone Bonus Application
During the visitor journey, zone bonuses are applied to the morale gain (not the absolute morale):
combinedBonus = themeBonus * designBonus
if combinedBonus > 1.0:
moraleGain = moraleAfterVisit - moraleBeforeVisit
bonusMorale = round(moraleGain * (combinedBonus - 1.0))
visitor.morale = min(100, moraleAfterVisit + bonusMorale)
Phase 5: Revenue Calculation
Source: .simulateDay:142-170
Revenue is now split between admission fees (ticket sales) and visitor spending (concessions + attractions):
// Visitor spending broken out: attraction revenue is computed separately from
// general concession spending so the day-summary P&L can show both.
visitorSpendingTotal = sum(visitor.totalSpent for all visitors)
attractionRevenue = sum(visitor's attraction spending across all zones) // .calculateAttractionRevenue
concessionRevenue = visitorSpendingTotal - attractionRevenue
// Admission: every visitor pays the zoo's current ticket price (default 10 ZB,
// configurable per zoo via ZooAccount.ticketPrice).
admissionFee = zooAccount.ticketPrice ?: 10
admissionRevenue = admissionFee * visitors.size()
baseRevenue = visitorSpendingTotal + admissionRevenue
totalRevenue = isSubscriber ? round(baseRevenue * 1.25) : baseRevenue
Revenue is credited via .
Per-Day P&L Snapshot Fields (persisted on `ZooAccount`)
After every daily sim the following fields are written to the account so the player (and the frontend FinancialPanel) can render the prior day's breakdown without recomputing. Source: feedback 5529a — "view attraction revenue".
| Field | Source |
|---|---|
lastDayAdmissionRevenue |
admissionFee * visitors.size() |
lastDayConcessionRevenue |
visitorSpendingTotal - attractionRevenue |
lastDayAttractionRevenue |
|
lastDayTotalRevenue |
totalRevenue (post subscriber multiplier) |
lastDayVisitorCount |
visitors.size() |
lastDayRevenueAt |
LocalDateTime.now() |
Lifetime stats are always updated (even if revenue is 0):
lifetimeRevenue += totalRevenue
totalVisitors += visitors.size()
Phase 6: Expense Processing
Source:
totalExpenses = upkeepCost + staffCost + eventExpenses
See Economy subsystem for full expense breakdown and debt mechanics.
Event revenue (VIP visits) is applied BEFORE expenses:
if eventResult.additionalRevenue > 0:
zooBucks += additionalRevenue
Phase 7: End-of-Day Cleanup
- Remove expired promotions:
- Recalculate all scores:
- Generate named visitor highlights: adds 0-2
VisitorHighlightrows to the day summary whentotalVisitors >= 5(fb 5783). Suppressed on dead days (< 5 visitors).
Simulation Result
Source: llitd.model.zoosimulator.DailySimulationResult
The simulation produces a DailySimulationResult containing:
| Section | Fields |
|---|---|
| Starting state | startBalance, isSubscriber |
| Events | eventResult (messages, multipliers, revenue, expenses) |
| Health | deadAnimals (list of names), treatedCount |
| Breeding | newborns (list of Animal), trainingsCompleted, enrichmentsExpired |
| Visitors | visitors (list), animalCount, attractionCount |
| Revenue | baseRevenue, totalRevenue |
| Expenses | upkeepCost, staffCost, eventExpenses, totalExpenses, actualExpensesDeducted |
| Balance | endBalance, debtAccrued, totalDebt, daysInDebt, hadDebtConsequences |
| Scoring | expiredPromotionsCount, zooScore |
This result is passed to for generating the narrative text output.
Event Interaction Matrix
Multiple events can occur on the same day. Their effects combine as follows:
| Interaction | Behavior |
|---|---|
| Rain + Escape | Visitor multiplier = 0.6 * 0.8 = 0.48 (stacks multiplicatively) |
| Sunny + Escape | Visitor multiplier = 1.3 * 0.8 = 1.04 |
| Rain + Sunny | Mutually exclusive (weather check is a single if/else) |
| VIP + Inspection | Both can trigger; revenue + penalty apply independently |
| Breakdown + Maintenance | If maintenance staff exists, breakdown is prevented entirely |
Visitor Count Examples
Assuming no events and no debt:
| Scenario | Animals | Attractions | Base | Bonus | Total |
|---|---|---|---|---|---|
| New zoo (1 animal) | 1 | 0 | 20-50 | 10 | 30-60 |
| Small zoo | 5 | 1 | 20-50 | 55 | 75-105 |
| Medium zoo | 15 | 5 | 20-50 | 175 | 195-225 |
| Large zoo | 30 | 10 | 20-50 | 350 | 370-400 |
| Empty zoo | 0 | 10 | - | - | 0 |
Performance: Per-Turn Persistence Cost
The whole ZooAccount graph (zones, habitats, animals, attractions, grid squares) is loaded and
saved as a single blob every turn: Redis is the primary store via (Kryo codec),
with MySQL zoo_simulator.zoo_accounts.account_data as the write-behind fallback. Per-turn cost
therefore scales with total zoo size — one full deserialize on load plus, when the turn changed
state, one full serialize on save.
For a maxed-out zoo this is the dominant per-turn latency. A real director's zoo reached 47 zones, roughly 2,000 habitats, 2,000 attractions, 4,400 grid squares, and 13,700 animals — about a 10 MB blob — and per-turn handling rose to about 7.85 seconds, past Alexa's response window. The cost grew with the zoo (about 3.1 seconds when smaller, about 7.9 seconds at peak), confirming it tracks blob size rather than any single computation. The in-turn UI projections () read only scalar fields, and the after-action scans (, counts) are single O(n) passes, so both are negligible next to the whole-graph serialize and deserialize.
Mitigation — read-only save-skip (ZooSavePolicy). Read-only turns (status, finances, score,
leaderboard, promotions, commands, tutorial, getting-started) make no persistent change, so
re-serializing and re-writing the whole graph for them is pure waste.
saveGameDetails now consults ZooSavePolicy.requiresPersist and skips the save for those actions
when the turn buffered no ledger row and flagged no incidental mutation, removing the
serialize-and-write half of the per-turn cost on the most common turns. The skip is conservative —
the default is to persist: any non-allowlisted action, any buffered currency or ledger write, and any
incidental mutation (nursery-graduation auto-assign, and test or admin character modifications, both
of which call ZooAccount.markStateChanged()) all persist exactly as before, so a misclassification
can never drop a write. Only the load-time cosmetic fields (lastPlayedDate, per-session glossary
tracking) go un-persisted on a skipped turn, and the next mutating turn writes the current value
anyway. The session-start launch turn is not a read-only action, so it always persists.
Residual (not addressed by the save-skip). The load-side deserialize is unavoidable with the single-blob design and still scales with zoo size, so an extreme zoo's mutating turns (buy, build, breed, advance day) remain heavy. Fully removing the cliff needs either bounding zoo growth or sharding the per-turn working set out of one blob — both are larger changes owned by the simulation and expansion design and are out of scope for the per-turn save-skip.
Performance: Advance Day on a Large Zoo
Advance day simulates up to 400 visitors individually and gives the rest the sample's average spend (). What a visitor's journey reads that does not change during the day is worked out once per day, not once per visitor or per animal:
- The visitor day (): the habitats visitors can reach, each with its zone, read from each zone's grid in one pass (); each zone's theme bonus times its design bonus; and each visited habitat's adjacency bonus, on first visit.
- The vets' effect on illness (): counted from the animals at the start of the health check, once, for every animal's illness roll.
Worked out per visitor and per animal, a zoo of one zone with 1,240 habitats on its grid and ten
thousand animals spent about 19 seconds in advance day in prod (hc_journey 19,072 to 19,818 ms), and
every advance day spoke the timeout line while the day still advanced: each sampled visitor walked every
animal of the zone to price its theme bonus and scanned the grid once per habitat, and every animal's
illness roll counted the whole zoo. On a fixture of that size (LargeZooFixture) the day took 14.0 to
14.9 seconds before and 0.17 to 0.67 seconds after, and holds it under
2.5 seconds.