Skip to main content

Simulation

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 Speech items 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-blob transactionHistory (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 to zoo_simulator.zoo_transactions (see economy.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 (say buy animal penguin) and returns without calling simulateDay. 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).

repeat on 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) — never lastDaySummary itself. The web/UI path is the one that replays the full stored detail; it has no Alexa response cache and falls back to lastDaySummary on repeat. tell me more is 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 day and what happened reach it too (ZOO_REVIEW_DAY -> ), which speaks one grouping of lastDaySummary per turn and rotates, wrapping at the end so the player can keep circling the same day. Advance-day resets ZooAccount.dayReviewCursor to 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 lastDaySummary by 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 while buildSummary runs, 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. buildSummary returns the character span of whichever section produced the pitch, if any; advanceDay pulls 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 into tell 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_SECONDS is 20. When the headline, the pitch and the next-goal teaser together run longer, fitSoftBudget rebuilds 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, then ZooAdvanceDaySpeech.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:

  1. Pick random habitat with animals
  2. Pick random animal in that habitat
  3. Update visitor morale based on animal appeal and habitat quality
  4. 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 ZoneTheme with 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

  1. Remove expired promotions:
  2. Recalculate all scores:
  3. Generate named visitor highlights: adds 0-2 VisitorHighlight rows to the day summary when totalVisitors >= 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.