Skip to main content

Management

Management

Status: IN PROGRESS — Tracked open issues; see Known Issues section

Last updated: October 27, 2026

Zoo Simulator -- Staff & Operations Subsystem

Status: IN PROGRESS — Tracked open issues; see Known Issues section

Zone Naming (feedback 416)

Players rename zones with name zone 1 Safari Savanna, rename zone 1 Big Cats, rename zone Africa Big Cats, or set zone name Frozen North. Handler: . Action: ZOO_NAME_ZONE. Parsing order:

  1. Strip leading command phrase (name zone / rename zone / name my zone / set zone name).
  2. Parse leading ordinal (1 / one / first, supports 1st/2nd/3rd) — targets that zone by 1-indexed position.
  3. Fallback: if no ordinal, try matching a zone theme (africa, arctic, etc.) at the start of the remaining text.
  4. Default: target zone 1 if no identifier supplied.

The remainder becomes the new zone name (capped at 50 chars, empty prompts for input). No cooldown, no cost.

Staff management, construction system, zone/habitat mechanics, scoring formulas, and operational commands. All mechanics extracted from Java source code.


Staff System

Source: ,

Staff Types

Source: llitd.model.zoosimulator.enums.StaffType

Staff Type Hiring Fee Daily Salary Function
VETERINARIAN 5,000 250/day Auto-treats up to 8 sick animals per vet per day
MAINTENANCE 3,000 150/day Prevents equipment breakdown events

Staff Capacity

  • No hiring cap -- unlimited staff allowed (a single hire command clamps to MAX_STAFF_HIRE_COUNT, economy.md § Multi-Quantity Purchase Ergonomics; repeat the command for more)
  • No firing refund -- hiring fee is one-time, non-refundable
  • Hiring requires sufficient Zoo Bucks for the hiring fee
  • Staff counts stored directly on ZooAccount: veterinarians, maintenanceStaff

Veterinarian Mechanics

Source:

maxTreatments = veterinarians * 8
  • Each vet treats up to 8 sick animals per day (reactive throughput; preventative coverage is a separate 5/vet knob)
  • Treatment costs deducted from zoo balance per animal
  • Treatment order: iterates through all animals (no priority system)
  • Treatment happens AFTER performDailyHealthCheck in Phase 2

Without vets: illness probability multiplied by 1.5x (from NO_VET_ILLNESS_MULTIPLIER).

Maintenance Staff Mechanics

Source:

if maintenanceStaff > 0:
  // Breakdown completely prevented
  message = "Equipment issue detected, but your maintenance staff fixed it quickly!"
else:
  // Breakdown causes repair cost
  • Any number of maintenance staff >= 1 fully prevents equipment breakdowns
  • No graduated benefit -- 1 maintenance worker is the same as 10
  • Breakdown event has 10% daily chance

Staff Input Parsing

Source: ,

Staff type is determined from user input text:

if inputText.contains("maintenance") || inputText.contains("staff"):
  staffType = MAINTENANCE
else:
  staffType = VETERINARIAN    // default

Staff View

Source:

Displays:

  • Vet count, daily salary, treatment capacity (vets * 8)
  • Maintenance count, daily salary
  • Total daily staff cost

Construction System

Source:

Zone Construction

Property Value
Starting designRating 50
Starting habitats Empty map
Starting attractions Empty map
ID format UUID

Zones contain habitats and attractions as nested maps keyed by ID.

Habitat Construction

Property Value
Starting qualityRating 60
Starting upgradeLevel 0
Starting animals Empty map
ID format UUID

Capacity by Habitat Type:

Habitat Type Capacity Cost
AQUATIC 10 4,000
GRASSLAND 8 2,000
SAVANNA 8 2,500
WETLAND 7 2,000
FOREST 6 2,500
JUNGLE 6 3,000
HATCHERY 6 3,000
NURSERY 10 3,000
MOUNTAIN 5 3,000
DESERT 5 3,500
ARCTIC 5 3,500

HATCHERY (fb 1176): 10th HabitatType. Egg-layer breeding pairs (PARROT, SNAKE, EAGLE, PENGUIN, FLAMINGO, OWL, CROCODILE, TURTLE, SHARK) placed in HATCHERY get a 0.80x gestation multiplier (hatch 20% faster). See animals.md Hatchery Habitat section for full mechanics.

NURSERY (fb 5783/5831/5838/5840): 11th HabitatType. Live-birth newborns auto-route into a NURSERY in the same zone if one exists with free capacity, instead of staying in the parent's habitat. Adult-graduation prompts now key off HabitatType.NURSERY in addition to the legacy name-substring heuristic. See animals.md Nursery section for full mechanics.

Habitat Upgrades

Source: ,

Target Selection (see ):

  • "upgrade habitat 2" → 2nd habitat by insertion order (globally, or within zone if zone scoped).
  • "upgrade savanna habitat" → first habitat matching the requested HabitatType.
  • "upgrade habitat in zone 2" → scopes the ordinal/type search to that zone.
  • No explicit target and two or more habitats that can still be upgraded → nothing is spent; the reply asks which one and reads the first five as numbered choices (name, zone, level). Each is an upgrade option keyed by habitat id, so a bare number answers the question as the pick, never as a quantity for another command.
  • No explicit target and one upgradable habitat → that habitat is upgraded. A maxed habitat is never the default target.
  • After an upgrade, the response lists other habitats the player can upgrade next so they can say "upgrade habitat 3".
Upgrade Effect
Capacity +2 animals per upgrade
Quality +10 quality rating per upgrade (cap 100)
Max Level 5

Cost formula: baseCost * 0.5 * 1.5^currentLevel

Full progression for a 2,500-cost habitat:

Level Cost Total Capacity Quality
0 (base) - 8 60
1 1,250 10 70
2 1,875 12 80
3 2,813 14 90
4 4,219 16 100
5 6,328 18 100

Attraction Construction

Attraction Type Cost Capacity Revenue/Visitor Appeal
VIEWING_PLATFORM 1,500 30 5 40
PLAYGROUND 1,500 40 8 50
SHOP 2,000 50 15 45
RESTAURANT 3,000 60 20 55
PETTING_ZOO 3,500 25 12 65
RIDE 5,000 80 25 75

The rows of zoo_simulator.attraction_type_defs are the definition of every type's cost, capacity, revenue and appeal (); the table above describes the first six, and where it and the rows differ, the rows are right. The other types are the snack stand, aviary, amphitheater and aquarium, and the restroom and hotel: restrooms are the cheapest build with a small spend per visitor, and the hotel, a safari lodge for an overnight stay, the dearest with the highest. A type builds only once its row exists.

Every attraction says what it is when it is built and when its details are read (): a ride is a safari train that carries visitors past the habitats, and each other type has its own sentence. A type name alone does not tell a player what kind of ride they bought.

Starting upgrade level: 0. Maintenance cost stored on model: constructionCost / 10 (but unused in actual upkeep calculation).

Building Attractions

Source: .

  • build attraction <type> builds one of the named type. A bare build attraction with no type renders the type menu instead of guessing (feedback 5371).
  • A quantity multiplies a single type: build 3 rides builds three rides at flat linear cost (no bulk discount).
  • Several named types in one command build one of each, in spoken order: build attraction ride and a shop builds one ride and one shop. The resolver collects every type joined by and / plus / &, not just the first substring match (report group 75 / finding 409 — the prior single-type resolver silently dropped all but the highest-priority type).
  • A misfit build ... attraction(s) phrasing the NLU routes to the habitat builder after a prior build (e.g. build two attractions) is recognized in and handed back to the attraction builder, rather than rejected as an unknown habitat type.

Attraction Upgrades

Source: ,

Target Selection (see ):

  • "upgrade attraction 2" → 2nd attraction by insertion order.
  • "upgrade the ride" / "upgrade shop" → first attraction matching the requested AttractionType.
  • "upgrade attraction in zone 2" → scopes the ordinal/type search to that zone.
  • No explicit target → first non-maxed attraction. Feedback 354: "we are not able to have a great any other attractions if we upgrade the 1st 1" — this was the original bug, fixed . Before the fix, every "upgrade attraction" command resolved to the first attraction, eventually maxing it and then failing forever.
  • After an upgrade, the response lists other attractions the player can upgrade next.
Upgrade Effect
Capacity +20 visitors per upgrade
Revenue +5 Zoo Bucks per visitor per upgrade
Appeal +8 appeal rating per upgrade (cap 100)
Max Level 5

Cost formula: baseCost * 0.6 * 1.6^currentLevel

Full progression for a RIDE (base 5,000):

Level Cost Capacity Revenue/Visitor Appeal
0 (base) - 80 25 75
1 3,000 100 30 83
2 4,800 120 35 91
3 7,680 140 40 99
4 12,288 160 45 100
5 19,661 180 50 100

Deletion Rules

Source: , isHabitatEmpty

  • Zones can only be deleted if they have no habitats AND no attractions
  • Habitats can only be deleted if they have no animals
  • Null zones/habitats throw (defensive design)

Demolish requires an explicit confirmation (destructive-verb safety, report group 163). never tears a habitat down on a single utterance. The first demolish habitat names the target empty habitat and asks "Are you sure you want to demolish X? Say yes to confirm, or no to cancel", arming a 60-second pending confirmation on ZooAccount.pendingDemolishHabitatId / pendingDemolishExpiresAt. The player confirms with yes (demolishes, re-checking the habitat is still empty) or cancels with no/cancel (habitat kept). routes a bare yes/no to ZOO_DEMOLISH_HABITAT while the confirmation is live, mirroring the batch-breed confirmation flow. A second demolish habitat re-prompts rather than confirming; only an explicit yes, yep, yeah, or confirm demolishes. Confirm options are yes/no only so the prompt stays audible. An ambiguous phrase like delete animal never completes a demolition: it is NLU-routed to the sell flow (SHOP_SELL), and even if a stray route reaches the demolish handler, the confirmation gate stops an instant teardown.

Demolish asks about the habitat the player named. "Demolish habitat 4 in zone 13" is resolved by the same zone-scoped ordinals upgrade uses (selectHabitatTarget), digits or words; "demolish habitat in zone 13" takes that zone's first empty habitat; a bare "demolish habitat" takes the first empty habitat in the zoo. A named habitat with animals is refused with how many, and a number the zone lacks is said back ("There is no habitat 7 in zone 2"); another habitat is never chosen instead. Demolish on a grid square that holds a habitat goes through the same confirmation (); an attraction's square removes the attraction from the zone. The zone's habitats and attractions are what it holds, and the grid only shows them: empties a square whose structure the zone no longer holds, so a demolished structure neither lingers on the grid nor is placed back on the next move.

Habitat-details animal list pagination

paginates resident animals through ListReadoutPaginator instead of concatenating every animal plus health into one Alexa turn. Health is spoken as plain health N (no parentheses). Overflow pages use the canonical say-more / previous vocabulary.

Spoken habitat ordinals

Players manage habitats as zone-local numbers ("habitat 2 in zone 9"), not one global list. view habitats, view zone N, view habitat N, VIP decoration, upgrade hints, and nursery-graduation move cues all speak and accept that form. Habitat numbers restart in each zone. Within a zone every type shares one sequence, and a new habitat's construction name takes one past the highest number any habitat in the zone carries (ZooLayoutOrdering.nextHabitatNumber), so after a demolish no two habitats in a zone are called by the same number.

ZooHabitatSelector.locateSpokenHabitat resolves a spoken habitat as follows:

  1. When a habitat type sits next to the word habitat (forest habitat 9, habitat forest 9), the construction name {Type} Habitat {N} is the habitat's own name. It matches in the target zone first. When the words named no zone, the one habitat in the zoo with that name wins, whichever zone it is in, so it is never asked about. Two habitats with that name resolve only in the target zone. Then the Nth habitat of that type in the target zone. forest habitat 9 is Forest Habitat 9, never a scrambled HashMap slot.
  2. Otherwise inspect, VIP, upgrade, move, buy, and place use the 1-based ordinal inside the target zone (§ The active-zone model). A move also knows the zone the moving animal lives in, so it never asks for a zone. Several zones and no resolved zone ask which zone on buy, place, and upgrade, and a build uses the newest zone and says so (§ The active-zone model). They never use the first-built zone. A named or active zone with a missing ordinal returns null. It never invents a habitat from another zone.
  3. Inspect and VIP read a habitat. When no zone resolves, they take the number as the zoo-wide build-order ordinal so older "habitat 15" speech still works, and the reply names the habitat's zone. A named or active zone does not take that fallback.
  4. Whole-zoo view filters without a type (view animals habitat 5) still use the global build-order ordinal.

accepts digits and spoken cardinals or ordinals 1-100 after the habitat keyword (habitat nine, ninth habitat). A spoken compound is one number however speech delivers it: habitat thirty one, habitat thirty-one and habitat 31 are all habitat 31, and habitat one hundred is habitat 100, the highest number read. extractZoneNumber reads the number after the zone keyword the same way. A leading quantity (buy 2 lions habitat 9) does not steal the habitat index. Prefer this over extractItemNumber when both a zone and a habitat number appear, because extractItemNumber takes the first digit and would turn "zone 9 habitat 2" into habitat 9.

A species word that is not adjacent to habitat (move polar bear to habitat 2) does not type-scope the ordinal to arctic.


Scoring System

Source:

Animal Welfare Score (0-100)

Source:

if zones == null || zones.isEmpty(): return 0  // no zoo built yet
if totalAnimals == 0: return 50  // zones exist but no animals (neutral baseline)

avgHealth = totalHealth / totalAnimals
avgHappiness = totalHappiness / totalAnimals
avgQuality = totalHabitatQuality / habitatCount

score = (avgHealth * 0.4) + (avgHappiness * 0.4) + (avgQuality * 0.2)

overcrowdingPenalty = min(30, overcrowdedHabitats * 10)
score -= overcrowdingPenalty

return clamp(0, 100, score)

Overcrowding: A habitat is overcrowded when animals.size() > maxCapacity.

Habitat Theme Bonus feeds happiness. Average happiness is one of the two 40%-weight welfare inputs. Placing an animal in a habitat that matches its preferred theme grants a one-time +10 happiness, and the daily updateAnimalStats adds +2/day while it stays on-theme — so thematic curation lifts the welfare score without ever blocking placement. Themes are a soft bonus, not a gate (off-theme placement is always allowed). Full model: animals.md Habitat Theme Bonus.

Component Weight Range
Average health 40% 0-40
Average happiness 40% 0-40
Average habitat quality 20% 0-20
Overcrowding penalty - -10 per overcrowded habitat, max -30

Visitor Satisfaction Score (0-100)

Source:

if zones == null || zones.isEmpty(): return 0  // no zoo yet

// Baseline: reward having a functional zoo at all
baselineScore = 0
if animalCount > 0 && habitatCount > 0:
    baselineScore = 25
else if habitatCount > 0:
    baselineScore = 10  // empty habitats show effort

varietyScore = min(35, (zoneCount * 8) + (habitatCount * 4) + (attractionCount * 5))
designScore = (avgDesignRating / 100) * 20
animalVarietyScore = min(20, uniqueSpeciesCount * 2)
promotionBonus = activePromotions.isEmpty() ? 0 : 10

total = baselineScore + varietyScore + designScore + animalVarietyScore + promotionBonus
return min(100, total)
Component Max Points Formula
Baseline 25 25 if animals+habitats exist; 10 if only habitats; else 0
Variety 35 (zoneCount*8) + (habitatCount*4) + (attractionCount*5), capped at 35
Design quality 20 (avgDesignRating / 100) * 20
Animal variety 20 uniqueSpeciesCount * 2, capped at 20
Promotions 10 10 if any active promotion, else 0

Thresholds for max variety score (35):

  • 4 zones + 0 habitats + 0 attractions = 32
  • 2 zones + 2 habitats + 3 attractions = 39 → capped at 35
  • 0 zones + 0 habitats + 7 attractions = 35

Conservation Score (0-100)

Source:

premiumBonus = min(40, premiumAnimalCount * 10)
diversityScore = min(40, uniqueSpeciesCount * 2)
upgradeScore = min(20, (totalUpgradeLevel * 20) / (habitatCount * 5))

total = premiumBonus + diversityScore + upgradeScore
return min(100, total)
Component Max Points Formula
Premium animals 40 premiumAnimalCount * 10, capped at 40
Species diversity 40 uniqueSpeciesCount * 2, capped at 40
Habitat upgrades 20 (totalUpgradeLevel * 20) / (habitatCount * 5)

To max conservation (100):

  • 4+ premium animals (40 points)
  • 20+ unique species (40 points)
  • Average upgrade level >= 2.5 across all habitats (20 points)

Overall Zoo Score (0-1000)

Source:

zooScore = round((animalWelfare * 3.0) + (visitorSatisfaction * 4.0) + (conservation * 3.0))
Component Weight Max Contribution
Animal Welfare 30% (x3.0) 300
Visitor Satisfaction 40% (x4.0) 400
Conservation 30% (x3.0) 300

highestZooScore tracks all-time best, updated after each scoring calculation.

Scoring Examples

Zoo Type Welfare Satisfaction Conservation Zoo Score
New empty zoo 50 10 0 190
Small (5 animals, 1 zone) 70 25 5 325
Medium (15 animals, 3 zones) 80 50 20 500
Large (30 animals, 5 zones, upgrades) 90 80 60 770
Perfect zoo 100 100 100 1000

Inspection Pass Threshold

Zoo inspections pass when:

  • zooScore >= 400 AND animalWelfareScore >= 50

This roughly corresponds to a medium-sized zoo with decent animal care.


Multi-Zone Management & Discoverability (fb 20035/20225)

Players reported that managing MULTIPLE zones was confusing. The underlying commands already existed (zone parsing, findTargetZone, build-in-zone-N — see Recent Fixes 2026-04 ); the gap was pure DISCOVERABILITY. This is a help-copy + next-step-hint surface, NOT a new system.

The zone-scoped commands that work today

Intent What the player says Where it resolves
List all zones (numbered) view zones, my zones, list zones, zones — one row per zone: "Zone N: name, theme. Habitats: H, Animals: A, Attractions: T"
One zone's breakdown view zone 2, check zone 2, zone details — numbered habitat + attraction list for that zone
Make a zone active enter zone 2, go to zone 2, visit zone 2, explore zone 2, walk around — sets ZooAccount.currentZoneId to that zone
Build a habitat in a specific zone build habitat savanna in zone 2 resolves explicit ordinal first
Upgrade a habitat in a specific zone upgrade habitat in zone 2 scopes the ordinal/type search to that zone
Build / upgrade an attraction in a zone build attraction in zone 2, upgrade attraction in zone 2
Name a zone name zone 2 Big Cats, rename zone 2 Big Cats, rename zone africa Big Cats
Build a new zone build zone africa (+ 9 other themes)

The active-zone model (the key concept players missed)

findTargetZone resolves the target zone by this priority, the placement contract for buy, move, place, build, and upgrade. Cite Z1 and Z3 in player-request-backlog.md.

  1. An explicit zone ordinal wins. reads zone 2, in zone 2, to zone 2, and zone number 2. A number that is not one of the player's zones resolves to nothing. It does not fall through to another zone.
  2. A named zone wins when the words cue it: in africa, asia zone, zone big cats. The cue matches the zone's name, its theme, or its custom theme. One match wins. Two matches resolve to nothing, and the game asks.
  3. Otherwise ZooAccount.currentZoneId, the active zone set by enter zone N, when that zone still exists.
  4. Otherwise, for a move, the zone the moving animal lives in. It is known, not guessed: move lion to habitat 3 reads habitat 3 where the lion is. A move that names a zone and no habitat number, move squirrel to zone 2, goes to the one habitat in that zone with room, else the one of those that suits the species, else it reads them out by the command that picks each, zone included: "Moved Pip the squirrel to Forest Habitat 1 in zone 2." Nurseries and hatcheries are never that destination ().
  5. Otherwise the only zone, when the player has exactly one.

A player with more than one zone and no explicit, named, active, or home zone is asked which zone on a buy, place, or upgrade. A build fills that gap with the newest zone, because a build adds something new and the zone built last is the one a player is filling, and it says so: "Built a ride in zone 2, your newest zone. Say enter zone 1 to build there instead." (findBuildZone). A zone the player named that does not exist still builds nothing. The game never places, builds, or upgrades into the first-built zone to fill that gap. Habitat numbers stay local to the resolved zone. A buy whose chosen zone is full stays in that zone and says so. It does not spill the animal into a different zone.

Premium display names win over the base species on buy, move, place, view, breed, and train. extractPremiumAnimal runs before extractSpecies, so white tiger never resolves as a plain tiger. That is the animals.md premium-name law.

A player who says enter zone 2 once, then build habitat savanna, lands the habitat in zone 2 without repeating the zone. They can also name the zone every time, build habitat in zone 2.

Where multi-zone management is surfaced (the discoverability changes)

  • ** (the "Building your zoo" help topic)** — rewritten to explain that a zoo is organized into zones, each zone holds its own habitats and attractions, and to teach enter zone 2 / go to zone 2 (active zone), build habitat savanna in zone 2 and upgrade habitat in zone 2 (target a zone directly), view zones / view zone 2, and rename zone 2 <name>.
  • ** (the "All commands" list)** — adds a dedicated Zones: group: view zones, view zone 2, enter zone 2, rename zone 2, build habitat in zone 2.
  • ** next-step hint** — once the player has 2+ zones, appends a hint: "To manage one zone, say enter zone 2 to make it active, or view zone 2 for its details. You can also target a zone directly, like build habitat in zone 2." Gated on zoneIndex >= 2 so single-zone players never hear it. Adds an enter zone option.
  • ** next-step hint** — when the player builds their 2nd+ zone, appends a hint naming the new zone number: "You now have N zones. Say enter zone N to make this one active, or build habitat in zone N to add a habitat here directly. Say view zones any time to see them all." Adds a view zones option. Gated on zoneNumber >= 2.

NLU coverage (reviewed — no gap)

The natural-language zone phrasings are already comprehensive: ZOO_ENTER_ZONE openings cover enter zone, go to zone, visit zone, explore zone, enter the zone, walk around; ZOO_VIEW_ZONE_DETAILS covers view zone, check zone, show zone, zone details; ZOO_VIEW_ZONES covers view zones, zones, my zones, list zones, show zones, see zones; ZOO_NAME_ZONE covers name zone, rename zone, set zone name. All are FREE_FORM openings so the trailing zone N / <name> is parsed by the handler. No missing NLU rows were needed.

Zone Management

Zone Model

Source: llitd.model.zoosimulator.ZooZone

Field Type Default Notes
zoneId String UUID Immutable
zoneName String User-chosen -
theme ZoneTheme Enum null for premium zones
designRating Integer 50 (standard) / 95 (premium) 0-100
appealBonus Integer null 0-100%
isPremium Boolean false 1.5x upkeep
customThemeName String null For premium zones
premiumBonus String null Display text like "2.0x visitor appeal"

10 Zone Themes

Source: llitd.model.zoosimulator.enums.ZoneTheme

NORTH_AMERICA, SOUTH_AMERICA, AFRICA, ASIA, EUROPE, AUSTRALIA, ARCTIC, AQUARIUM, RAINFOREST, DESERT

11 Habitat Types

Source: llitd.model.zoosimulator.enums.HabitatType

SAVANNA, FOREST, JUNGLE, AQUATIC, ARCTIC, DESERT, MOUNTAIN, WETLAND, GRASSLAND, HATCHERY, NURSERY


Leaderboard

Source: llitd.model.zoosimulator.ZooLeaderboardEntry

Tracked leaderboard fields:

  • skillCustomerId
  • zooName
  • zooBucks (current balance)
  • totalVisitors (lifetime)
  • lifetimeRevenue

Maps to database view v_zoo_leaderboard.

Visit discoverability

A returning "visit zoo" uses this leaderboard as the weekly spotlight: the top zoo that is not the visitor. The same picker also names zoos the visitor recently walked and zoos they rated. The reward, the once-a-week rating pay, and the restart dropout are in expansion-model.md under Social / Visit Layer (Q4, B4, C1). Golden Tickets stay out of that loop. Players earn Golden Tickets free from the daily and monthly reward payouts and from a top three finish in the monthly season below, and buy them in the premium shop where Amazon sells in-skill purchases. A Golden Ticket shortfall (ZooInsufficientFundsRenderer.renderGoldenTickets) names the free routes and offers the daily reward as an option in every region; it offers buy golden tickets only where purchases are sold.

Weekly Leaderboard Reward (feedback 867/869/871)

When showLeaderboard runs, the caller's rank is computed and a once-per-week payout is granted:

Rank Payout
1 5000 Zoo Bucks
2-3 2500 Zoo Bucks
4+ none

Idempotency is enforced by zoo_simulator.leaderboard_rewards with composite PK (customer_id, week_start) where week_start is the Monday of the current ISO week. INSERT INTO ... leaderboard_rewards on a duplicate key is silently skipped (no double pay). A successful insert triggers and a voice-facing congratulations line.

Before this, Zoo leaderboard had no reward wire-up at all ( covers DARK_TOWER + DARKNESS_FALLS only), so hitting rank 1 paid nothing.

Monthly Seasons (fb 870/880/882/888/904/933/936/950)

Driver. AMAYUZ2L's 24 billion Zoo Bucks dwarfs the 1.58 billion runner-up. Permanent Zoo Bucks leaderboards lock out competition forever and make rank rewards meaningless. Monthly resets reopen the ladder for everyone every 30 days; top-3 keeps mid-tier players motivated to push for podium.

Cadence. A new season runs every calendar month. The first live season is Season 1, starting 2026-06-01. The MySQL event zoo_simulator.evt_zoo_sim_season_rollover fires at 00:01 on the 1st of every month in the database's zone, America/New_York (@@global.time_zone; season rows run from the 1st at 00:00 to the last day at 23:59:59 in that zone), and calls the procedure zoo_simulator.sp_zoo_sim_season_rollover. Manual trigger for testing on a DB copy: CALL zoo_simulator.sp_zoo_sim_season_rollover(); (or via ).

Standing. A season is a race for that calendar month's visitors, counted only among the directors who opened their gates that month. A zoo's visitors for the month are ZooAccount.seasonVisitors, kept by ZooSeasonVisitors where the day's visitors are added, and started again at zero on a zoo's first visitor of a new month. That month turns at 00:00 UTC, four hours before the rollover runs, and days before it when the event misses and rolls over on read. So the count a new month replaces is kept as ZooAccount.priorSeasonVisitors and priorSeasonVisitorsMonth, and the rollover ranks the closing month from whichever count names it (gen_prior_season_visitors and gen_prior_season_month): a zoo that plays on the final night keeps its place. The standing reads them through the stored generated columns gen_season_month and gen_season_visitors on zoo_simulator.zoo_accounts (). The readout:

"Season 5. You are rank 4 of 61 directors who opened their gates this month, with twelve thousand visitors so far. Rank 3 is two thousand ahead. This season ends in 18 days. Top three earn Golden Tickets and a unique zone skin."

A zoo that has not opened its gates this month is told the season is a race for this month's visitors and to say advance day to join it. The Zoo Bucks board, every Zoo Buck a zoo has banked, stays beside the season on view leaderboard, so a long-time leader keeps that board and its weekly prize. The season status readout ends on that board too, "On the all-time Zoo Bucks board you are rank 1 of 812, with four billion Zoo Bucks." (), so the rank-1 director hears what they still hold beside the month's race.

Reward tiers. Every season's payout schedule is its own prize_pool_json, written by the rollover when it opens the season. The grant () reads it through , so a season pays exactly what it was opened with, and a season opened before the tiers pays its top three only. The season status readout speaks this pool too, so a player hears what the grant will pay. A season the current procedure opens pays:

Rank Golden Tickets Zoo Bucks Cosmetic Zone Skin
1 3 none ZONE_COSMETIC_SEASON_{N}_FIRST (Gold palette)
2 2 none ZONE_COSMETIC_SEASON_{N}_SECOND (Silver hue shift)
3 1 none ZONE_COSMETIC_SEASON_{N}_THIRD (Bronze hue shift)
4 to 10 1 none none
11 to 100 none 10,000 none
101+ none none none

The procedure's JSON_OBJECT is the definition of these amounts; where this table and it differ, the procedure is right.

Currency choice rationale: Golden Tickets couple to the ISP funnel (subscribers care about GT scarcity; non-subscribers see a tangible path to premium content), so they stay with the top ten. Ranks 4 to 10 earn the same single ticket as third place without the trophy skin, which keeps the podium worth more. Ranks 11 to 100 earn Zoo Bucks. The season ranks visitors, so a Zoo Bucks prize does not change the month's standing. 10,000 Zoo Bucks is ten daily logins' Zoo Bucks, two tigers, or twice the weekly first-place payout, enough for a mid-ladder zoo to feel. The Golden Tickets a season pays are the prize pool in sp_zoo_sim_season_rollover, not a number restated here.

Cosmetic catalog. v1 cosmetics are palette/hue-shift variants of existing zone tiles seeded by the rollover procedure (per the design pass: original AI art not required for v1). The zoo_simulator.zone_cosmetic_catalog table holds per-season-per-tier palette_hex + hue_shift_deg for the frontend to apply.

Per-player grant path. The MySQL procedure writes season_standings rows with rewards_granted = 0. A Spring @Scheduled sweeper in runs every 5 minutes, claims rows with final_rank BETWEEN 1 AND 3, plus ranks 4 to 100 for a season whose pool has tiers, and grants each currency the pool names for that rank through — the same live-cache write path admin manual grants use, so an active session cannot stomp the grant. Cosmetic unlocks land in zoo_simulator.zone_cosmetic_grants (idempotent on (customer_id, cosmetic_key)). On success the row flips to rewards_granted = 1 with granted_at = NOW(). A row that cannot be paid (a first refused grant, a season with no readable pool, or a grant that throws) is set to rewards_granted = 2 () and alerts once: it leaves the claim, so a failure never holds a batch slot and never blocks another row of any season. Setting it back to rewards_granted = 0 retries it whole. A refusal after one currency landed still closes the row at 1 and reports the shortfall, so the landed currency is never paid twice. A rank the pool pays nothing for is closed with nothing granted.

Rollover SQL pattern. The procedure does these things in order:

  1. Look up the active season (status = 'active', highest season_number).
  2. INSERT IGNORE the top 100 by that month's visitors (gen_season_visitors desc where gen_season_month is the closing season's month and the count is above zero) into season_standings keyed by (season_id, customer_id). RANK() window function over the same order produces final_rank. The procedure body is backend/src/main/resources/db/zoo-season-visitor-rank-and-tiers.sql: it ranks gen_season_visitors for the closing month, stores that count in final_zoo_bucks (the standings score column), and writes the tiers into the new season's prize pool. It does not read lifetime Zoo Bucks and it does not write season_baseline.
  3. UPDATE the active season to status = 'archived'.
  4. INSERT an audit row into game_log.cleanup_audit_snapshot with cleanup_type='date_based', target_key='zoo_season_rollover_s{N}', stale_rows = standings_count, notes='Season {N} archived; season grants pending Java sweeper'.
  5. INSERT a new season covering the current calendar month (DATE_FORMAT(NOW(), '%Y-%m-01') to LAST_DAY(NOW())) with status='active' and a prize_pool_json carrying the top three and the tiers array.
  6. INSERT three new cosmetic catalog rows for the season's top three.

Audit query. Check season rollover health any time:

SELECT s.season_number, s.status, s.started_at, s.ended_at,
       (SELECT COUNT(*) FROM zoo_simulator.season_standings ss WHERE ss.season_id = s.season_id) AS standings,
       (SELECT COUNT(*) FROM zoo_simulator.season_standings ss
        WHERE ss.season_id = s.season_id AND ss.rewards_granted = 0
          AND (ss.final_rank <= 3 OR JSON_CONTAINS_PATH(s.prize_pool_json, 'one', '$.tiers'))) AS pending_grants
FROM zoo_simulator.season s
ORDER BY s.season_number DESC LIMIT 6;

SELECT target_key, snapshot_at, stale_rows, severity, notes
FROM game_log.cleanup_audit_snapshot
WHERE target_key LIKE 'zoo_season_rollover%'
ORDER BY snapshot_at DESC LIMIT 6;

pending_grants > 0 for any season older than a couple of minutes means the Java sweeper is wedged — check application logs for Zoo sim season rollover. Rows at rewards_granted = 2 failed once and alerted; they wait on a fix and a reset to 0, never on the sweep.

Voice surface. Players read their standing via , routed from the action ZOO_SIM_SEASON_STATUS (NLU rows: leaderboard, rankings, standings, how am I doing, season status, season, my rank, season standings, monthly leaderboard). Output names the season number, the player's rank N of M directors who opened their gates this month, with that month's visitors, days remaining, when rewards pay ("Season rewards pay once, when the month ends"), each top three place's tickets read from the active season's own prize_pool_json (, the pool the grant pays from), and, when the pool has tiers, one sentence naming each tier ("Places 4 to 10 earn 1 Golden Ticket each, and places 11 to 100 earn 10,000 Zoo Bucks each."). is the wording. A golden ticket question reaches it only when it asks when or whether tickets pay; "how many golden tickets do i have" reads the zoo status, "get my golden ticket" collects, and "get more golden tickets" keeps NLU's route, which is the shop (). The daily reward never answers a season reward question: it claims a reward rather than saying when the season pays. The rank counts the month's visitors, the same count the rollover procedure writes. The caller's own visitors follow the zoo they are playing between database writes.

Why not character-season migration like Darkness Falls. DF migrates characters between season tiers (design/games/darkness-falls/seasons.md). Zoo doesn't have characters — players accumulate one zoo that persists forever. The leaderboard is the only thing that resets monthly; no animals, zones, or progress are touched.


NPC Sketch — Named Staff + Visitor Highlights (fb 5783)

Driver. Player AMAWNE6X asked for "MPCs… maybe staff or maybe visitors". The world feels emptier when veterinarians and visitors are anonymous integer counts. Adding named characters with personality voice flair makes the zoo feel populated without introducing dialogue trees.

Scope is intentionally a sketch. This round ships named staff + named visitor highlights only. Petting-zoo breedable animals (a separate ask in 5783) and NPC dialogue trees are out of scope and tracked as deferred design notes in this doc — NOT queued as follow-up tasks.

Named Staff Members

Every hire (vet OR maintenance) creates a NamedStaffMember row appended to ZooAccount.staffMembers:

Field Type Notes
staffId String UUID Stable for the life of the staff member
name String One of ~40 first names picked from a curated pool (no last names; voice-friendly)
role StaffType VETERINARIAN or MAINTENANCE
personality Personality Reused from the existing animal Personality enum (SHY, PLAYFUL, AGGRESSIVE, CALM, CURIOUS, LAZY, ENERGETIC) so we don't add a new dimension
hireDate LocalDate For voice "Dr. Maya has worked here for 12 days"

Voice flair on view staff. Instead of "Veterinarians: 3", the readout becomes:

Your veterinarians: Dr. Maya, a calm vet hired 5 days ago. Dr. Tobin, an energetic vet hired 3 days ago. Dr. Helena, a curious vet hired today.

For staff counts > 4, the voice line collapses: "Plus 2 more veterinarians" after the first 4 named entries. Voice noise stays bounded.

Personality affects nothing mechanical in this round. The personality is a voice/flavor surface only. If we later wire personality into behavior (e.g. ENERGETIC vets treat one extra animal per day), the data model is already in place.

Fire flow. "fire veterinarian" removes the oldest vet first (least-recently-hired) and names them in the voice line: "Fired Dr. Helena." Players can later request named-fire ("fire Dr. Helena") as a follow-up.

Idempotency under cache writeback. Staff are appended to a list on ZooAccount; the existing Redis writeback covers the entire account JSON, so there is no separate cache key to invalidate.

Visitor Highlights

picks up to 2 named visitors per day (when total visitor count >= 5) and stores them on DailySimulationResult.visitorHighlights. Each highlight has:

Field Notes
visitorName First name pulled from the same name pool as staff (different draws)
descriptor Two-word descriptor: e.g. "a school teacher", "a wildlife photographer", "a wide-eyed kid", "a season-pass regular"
highlightLine One sentence describing what they enjoyed: "Maya, a school teacher, was thrilled by your white tiger."

The day-summary builder () appends one to two highlight lines to the daily readout before the financial summary. Suppressed when total visitors < 5 (no highlight on dead days). Voice cost: ~1-2 sentences per day, much less than the existing event readout.

No persistent visitor characters in this round. Highlights are ephemeral — they appear in the day's readout and are not stored long-term. This keeps ZooAccount small (already 200+ fields) and avoids serialization growth.

Petting-Zoo Breedable Animals (deferred — design note only)

Player 5783 also asked: "you could breed and buy animals for the petting zoo as like a little side thing". The right shape is a small set of petting-zoo-only species (goats, rabbits, chickens, ducks) with their own simplified breeding cycle (no genetics, no rare variants, fast cooldowns) wired to the PETTING_ZOO attraction.

Implementing this requires:

  1. New enum PettingZooSpecies (4-6 entries)
  2. New attraction-bound animal subset on PETTING_ZOO attractions
  3. Simplified breeding pipeline (similar to existing but distinct)
  4. Voice surface: "view petting zoo animals" / "buy petting zoo animals" / "breed petting zoo animals"

Estimated 1-2 days of focused work. Per the queue-task fragmentation rule, this is logged as design discussion here rather than queued as a separate task. Surfaces in the next "Zoo deeper" pass.

NPC Dialogue Trees (deferred — design note only)

Player 5783 was clear: "MPCs might be nice not to have a conversation with but maybe MPC characters". Dialogue is not wanted. Personality flair on existing commands is sufficient. Do NOT add dialogue branching.


QoL Player Commands (fb 18400 design pass)

The following commands were accepted in the round-2 design pass and are bundled in queue . Mechanics summarized here; full details live in the individual subsystem docs (animals.md for breeding/genetics commands).

count-by-species (fb 880, 882)

how many of <species> do I have / count <species> — voice output: You have 8 lions: 3 adult males, 4 adult females, 1 juvenile. Includes age-group breakdown when count <= 20. Uses ZooAccountNavigator.streamAnimals with species filter.

feed-all (fb 933)

feed all / feed all animals — one batch op feeds every animal in the zoo. Cost computed upfront and deducted once. Voice: Fed 47 animals. Cost: 1410 zoo bucks. Average happiness +5.

habitat-choice on nursery graduation (fb 888, prior session + )

When an animal hits ADULT stage via .processGrowthStages and the current habitat is a nursery, the player is prompted on the next play turn. The prompt enumerates up to 3 available non-nursery habitat numbers so the player has concrete options to repeat back, rather than being told to pick "a habitat number" with no list. Voice rule: no parens, no slashes, no brackets; conjunctions only.

Examples:

  • One habitat with room: Pip is now an adult and ready for a permanent habitat. Say move pip to habitat 2, or ignore it and I will place them automatically.
  • Two with room: Pip is now an adult and ready for a permanent habitat. Say move pip to habitat 2 or habitat 3, or ignore it and I will place them automatically.
  • Three or more with room: Pip is now an adult and ready for a permanent habitat. Say move pip to habitat 2, habitat 3, or habitat 4, or ignore it and I will place them automatically.
  • No non-nursery habitat with room: Pip is now an adult but every habitat outside the nursery is full or missing. Build or upgrade a habitat, or I will place them automatically when you ignore this prompt.

Implementation: walks zones in insertion order, counting the 1-indexed habitat position the player can refer to in "move animal to habitat N". Auto-assign fallback only after the player ignores the prompt twice. Driver: players reported finding adult animals stuck in nursery slots they had not noticed, and asked for actionable numbers instead of unspecified "a habitat number".

unique-animal-names (fb 904, prior session + )

Baby names and market names come from their own pools per sex, both in ZooAnimalNames (the arrays are the definition of the names and their number), so every animal in a zoo has a distinct name, bought or born; § Which animal a name means is the rule. The legacy 3-arg createOffspring(male, female, pair) overload, for callers without an account in scope, picks from the raw pool with no uniqueness check.

start-breeding-all per-species (fb 1096/1097)

See animals.md "Start-Breeding-All Batch".

Other Round-2 Commands

  • start over / restart zoo — see Restart Policy in economy.md. Queue only adds NLU synonyms; reset logic already shipped in .
  • view genetics babies — see animals.md "View-Genetics Babies Filter".
  • incubator voice phrasing — see animals.md "Voice Reskin for Egg-Layers".

Account Persistence

Source: ,

  • Account stored as JSON blob in database (MyBatis mapper)
  • Loaded/saved via (contains skillCustomerId + ZooAccount)
  • Cache wrapper uses @Alias("") for MyBatis
  • handles JSON serialization/deserialization
  • The Redis zoo_account cache (write-behind, 7-day TTL) encodes the account with FieldTolerantKryo5Codec, which matches fields by name, so an account cached before a field was added to ZooAccount, ZooZone, Habitat or Animal still reads after the deploy that adds it. Removing a field makes entries cached before the removal unreadable (they reload from MySQL, and an unflushed write on one is lost), so a field is retired by leaving it in place until the cache's entries have expired.

Account Creation

Starting values:

zooBucks = 5000
goldenTickets = 0
veterinarians = 0
maintenanceStaff = 0
debt = 0
daysInDebt = 0
totalAnimalsBorn = 0
rareVariantsDiscovered = 0
highestGeneration = 1
totalBehaviorsTrained = 0
totalEnrichmentsPurchased = 0

Command Parsing

Source: , , ZooInputSanitizer

When a command is missed

Most Zoo misses are live options heard a word off: "100 hide veterinarians" is hire veterinarian, "day on" is advance day, "veal babies" is view babies, "stop breathing turtles" is stop breeding turtles. answers a miss in this order, and anything it does not answer is the shared fallback's:

  1. An opening word ("start", "play") starts the zoo.
  2. A bare attraction type ("petting zoo"), said in a zoo with a zone, is taken as building it: "I will take that as build attraction petting zoo." The provider redirects to ZOO_BUILD_ATTRACTION, and runs the redirected command on one more pass of the chain, since the Zoo handlers sit ahead of the fallback.
  3. A miss near an option the player heard last turn is offered back as that option: "Did you mean hire veterinarian? Say yes, or say the command again." The options are GameBasicProperties.getOptionsHeardLastTurn(), the table the request's first clear dropped. Nearness is ZooNearestOption: at least half an option's words are heard, each as itself, its plural, or a word a sound or two off, and continue, main menu, help and stop reading are never a guess. The offer is the account's pending suggestion (pendingSuggestedCommand, its action and option key, live two minutes); a yes runs it in exactly as if the player had said it, and any other answer drops it. The option list leads with the offered command, then advance day, view animals and status.

Known Command Parsing Issue (#6016)

The game engine uses key-value command format, which is broken for Zoo Simulator. Only simple commands work:

  • buy animal [species] -- works (simple format)
  • Key-value pairs like build habitat type=savanna name=My Habitat -- broken

Commands are routed through FreeTextDeclarationAction enums:

  • ZOO_ADVANCE_DAY -- advance day
  • ZOO_VIEW_ANIMALS -- view animals (synonyms include: view animals, review animals, check animals, show animals, list animals, my animals, see animals)
  • ZOO_BUILD_HABITAT -- build habitat (handler falls back to originalText when matchedKey lacks a recognized biome, so 'build habitat savannah' resolves even when upstream tiers strip the parameter). Accepted phrasings include build/create/add/new/buy plus habitat, with or without an article: 'build habitat savanna', 'build the habitat', 'buy habitat aquatic', 'buy the habitat'. The 'buy habitat' and 'buy the habitat' FREE_FORM one-shot rows route to ZOO_BUILD_HABITAT, not the generic 'buy X' -> ZOO_ADD_ANIMAL path; a typeless request prompts 'Please specify a habitat type' and a bogus type isolates just the bad token ('I did not recognize the habitat type purple').
  • ZOO_HIRE_STAFF / ZOO_FIRE_STAFF -- staff management (synonyms: hire vet, hire veterinarian, recruit vet, veterinarian alone → hire intent. Bare animal name alone (e.g. polar bear, zebra) → ZOO_ADD_ANIMAL with matchedKey literal-valued to the animal name. .)
  • ZOO_VIEW_STAFF / ZOO_VIEW_EXPENSES -- view staff/expenses
  • ZOO_VIEW_SICK_ANIMALS -- sick-animal roster (synonyms: view sick animals, sick animals, show sick animals, check sick animals, any sick animals, are there sick animals, are any animals sick, do you have sick animals, do you sick animals (ASR), treat sick animals. All Tier 4 ZOO_SIMULATOR direct-match. Epic 8426 / .)
  • ZOO_VIEW_PREMIUM_SHOP -- premium shop
  • ZOO_FINANCES -- financial overview
  • SHOP_BUY -- generic buy handler
  • BASELINE_STATUS -- zoo status

Staff Type Parsing

if input contains "maintenance" or "staff": MAINTENANCE
else: VETERINARIAN (default)

Premium Animal Parsing

// Try display name match (case-insensitive)
for each PremiumAnimal:
  if input contains displayName: return animal

// Try enum name match with underscores -> spaces
for each PremiumAnimal:
  if input contains name.replace("_", " "): return animal

// Default fallback
return WHITE_TIGER

Animal Views, Pagination & Sell Resolution

View-animals pagination (all filters)

paginates every listing at ANIMALS_LIST_LIMIT = 15, including the filtered views (species, premium-name, habitat-type, zone-theme), not just the full roster. A large same-species roster (e.g. 300-plus lions, fb 7123) would otherwise concatenate every match into one oversized response and breach the Alexa render ceiling. When a filtered match set overflows a page the handler emits "More, N remaining" and "Previous" options carrying the page in the option key (PAGE_N on ZOO_VIEW_ANIMALS_MORE, canonical vocabulary from ); past-the-end navigation answers "You are already on the last page" and re-renders (no silent wrap). When the whole set fits on a page the output is unchanged from the pre-pagination form (header, habitat-grouped entries, and a "Total: N ..." footer). The inventory, genetics, premium-shop, build-menu, and staff-roster listings page the same way (page-in-key on their own actions).

The active filter is persisted on ZooAccount.lastAnimalsFilter (encoded descriptor: species:LION, premium:White Tiger, habitatType:SAVANNA, theme:AFRICA, or null for the full roster), mirroring lastGeneticsFilter, so a later "next page" (viewAnimalsMore) stays scoped to the same filter instead of falling back to the whole zoo. The single-habitat view (view animals habitat N / view animals in forest habitat 9) uses the spoken-ordinal rules above; it is bounded by habitat capacity and clears the stored filter.

Which animal a name means

One resolver answers "which owned animal does this name mean": ZooAnimalNameResolver (backend/src/main/java/llitd/service/zoosimulator/). Sell, move, genetics, training and feeding all call it; none keeps a private name matcher. Its code is the definition; this section describes it, and where the two differ the code is right.

  • What a name matches. An animal answers to a name when that name appears in what the player said as whole words, case and punctuation ignored ("saber tooth tiger" matches "Saber-Tooth Tiger"). The letters of a name inside another word are not the name: "sell panda" never means an animal called Pan. The longest name said wins, so "tiger lily" means Tiger Lily, not Lily. Sell and genetics match the personal name only; move, training and feeding also match a premium animal's display name.
  • Every match, in a stable order. Every animal answering to that longest name is a candidate, ordered by acquisition date and then animal id. The order never comes from the account's maps, which are rebuilt in a different order whenever the account loads from storage, so the animal called number 2 is the same animal on the next turn. The genetics list uses this order too, so its "Pip 3 of 5" is the animal "view genetics pip 3" opens.
  • A species said with the name narrows it. When several animals share the name and the player also said a species, only the candidates of that species stay: "sell elephant named Apollo" is the elephant when a lion is called Apollo too. Any word of the species counts ("elephant" for African Elephant), and a premium display name counts for a premium animal. A species that none or all of the candidates are leaves them as they were, so the which-one question still asks.
  • A spelled name is the name. A name spelled letter by letter reaches the game as single letters ("j e n n y"); three or more single letters in a row are read as the word they spell. One or two stay apart, so "sell a lion" keeps its article.
  • The number picks one. A trailing number picks among the candidates: "pip 2", "pip number 2", "pip #2" and "pip two" all mean the second animal named Pip. A number out of range picks nothing. A single candidate is chosen whatever the number.
  • A name that ends in the number comes first. When an animal's own name ends in the number the player said plainly ("pip 2", "pip two"), that animal is meant, before the number is read as a pick: a zoo can hold an animal named Pip 2 beside one named Pip. "pip number 2" and "pip #2" are always the pick, which is why the which-one question says them that way.
  • Every animal has a name of its own. Every new animal, bought or born, is named by ZooAnimalNames: one word that no animal in the zoo has, case ignored, from its own pool, then every other pool, then a pool of extra names, and past all of those built from syllables. A name is never two names joined, never a trailing number, which would read as a pick, and never a species word, so "move the bear" never means an animal called Bear. A zoo named before this rule is put right once, on its first turn (ZooAccount.uniqueNamesGiven): every animal that repeats the name of an animal acquired before it, and every name made of two pool names joined, is given one of its own, every other name stays, and the player hears it in one line: "Some of your animals shared a name, so each now has one of its own. Pip the zebra is now Juniper." Two animals a player names alike afterwards keep those names, and the which-one question tells them apart.

Sell and move never act on a guess. A sale cannot be undone and a move is not undone for the player, so when two or more animals answer to the name and no number picked one, nothing is sold or moved. The game asks which one instead: "You have 2 animals named Pip, so nothing is sold yet. Which one do you mean? Number 1 is Pip, the female lion in Savanna Habitat 1, who is expecting. Number 2 is Pip, the male zebra in Savanna Habitat 1. Say sell Pip number 1, or sell Pip number 2." Each candidate is described by species and habitat, plus whatever tells same-named animals apart: the growth stage when not adult, the zone when two habitats share a name, the age when descriptions still match, and whether a female is expecting. Up to six are read out and the rest counted. Move asks the same way and keeps the destination ("Which one should move to habitat 2?").

Naming asks too. "Name animal lion Pip" with several lions, and no one of them the only lion without a name, renames nothing: "You have 3 lion animals, so none is renamed yet. Which one should be named Pip?" Each lion is read out and offered as "name Leo Pip" under an option key carrying that animal and the new name (ANIMAL_ID_<animalId> Pip), so the answer renames the lion picked. A single lion, or the only unnamed one among them, is named straight away.

A move names a kind of animal as often as one animal: "move lion to habitat 2", "move white tiger to habitat 2". The kind is read by the shared species parser, and ZooAnimalNameResolver.resolveKind answers which animals of it the words mean: every owned animal of that species, or with that premium display name, in the stable order. A personal name said beside the kind picks among them ("move lion Nala to habitat 2"), and so does a trailing number ("move lion number 2 to habitat 2"). With two or more and no name or number picking one, nothing moves and the which-one question above is asked, with the same numbered options. A single animal of the kind moves straight away. A hatchling waiting in the hatchery is one of the candidates like any other, described by its growth stage and habitat.

A species word means the kind the zoo keeps (ZooAnimalNameResolver.ownedKind). In a zoo of polar bears and no bears, "move the bear to habitat 1" is the polar bears: when no owned animal is the species the word names, the one owned species whose spoken name holds that word is meant. A species the zoo has none of, said inside a name it does have ("move tiger lily"), is read as that name. A species is spoken by its name, "polar bear", never by its id.

The which-one question of a move ends. Each candidate also answers to its number alone ("number 2", "the second one"). When the answer picks no one and asks the same question again, it is asked once more by number only, with "keep them" to leave them: "I still need to know which one. Number 1 is Tigerlily, number 2 is Hera, and number 3 is Fawn." A third answer that picks no one ends it with nothing moved and the one form that names a single animal. The count of asks is kept on the player's counts (ZOO_MOVE_PICKER_ASKS), because the options a question offered are cleared before its answer reaches the handler, and any move resets it.

Each candidate is offered as the command that picks it ("sell Pip number 2", "move Pip number 2 to habitat 2") under an option key carrying that exact animal (ANIMAL_ID_<animalId>, followed by the destination for a move). How an answer is read, in ZooAnimalNameResolver.resolveChoice:

  1. The spoken words win when they restate the shared name. An option can be reached by a sentence that only repeats the ambiguous command, because the fuzzy option step can take a near spelling of an option's words for that option: "sell pip number one" is a few edits from "sell Pip number 2" ( § Story Options Matching, Step 6). A plain repeat such as "sell pip" therefore asks again, and "sell pip number 2" picks the second.
  2. Otherwise the key picks the animal: an option chosen by its number or by its words.
  3. A key whose animal is no longer owned sells or moves nothing, and says so. It is never replaced by a guess from the words.

Genetics, training and feeding never lose or move an animal, so a name several animals share acts on the first candidate in the stable order, as before; the number form picks another.

Sell resolution

resolves a named animal through the resolver, from the spoken text and then the matched key, before any species match, so "sell tiger Bella" sells Bella rather than the first tiger by species. When the player named no owned animal, the species they named decides, read by the shared species parser , so "sell polar bear" names a species: its only sellable animal is sold, and two or more are asked about. Premium animals are excluded from the sell flow throughout.

The parser reads a species or premium display name only as whole words, alone or with an -s or -es plural: "lion", "lions", "polar bear", "polar bears", "white tiger", "saber tooth tiger" and species=polar_bear all resolve, and "wolves", "dear" and a few other spoken forms are listed aliases. The letters of a species inside another word are no species. "sell lionel", in a zoo with one lion and no animal called Lionel, names no animal, so nothing is sold and the player hears that no animal called lionel was found. Move, buy, feed, name, view and breeding read the species through the same parser, so the rule holds for every zoo command.

A resolved animal whose breeding pair is expecting (BreedingStatus.isExpecting), the mother or the father, is not sold on that turn. She is told she is expecting, or he is told he is the father of an expected litter, and that selling her or him loses the litter. The game asks yes or no and arms ZooAccount.pendingSellAnimalId with pendingSellExpiresAt, the same window the demolish confirmation uses. Yes, and the same affirmatives demolish already accepts, sells. No, or any other command, cancels and sells nothing. When the window passes the confirmation is gone, and the next sell is a fresh request. An animal whose pair is not expecting is not asked, and the sale is unchanged. When the player names a species with two or more sellable animals ("sell lion"), the which-to-sell list names the elderly animals and the adults that cannot breed in sentences after the names ("Sage is elderly. Rex cannot breed. Consider selling them.") so the player can consider selling them. An adult cannot breed when breeding itself would refuse it, : sick or weak; a cooldown after a litter passes on its own and is not flagged. An animal under breeding age is never offered for sale on that account: it is named after the cue with the age it starts breeding ("Cub is too young to breed yet, and will breed once grown, from N months old."), N read from GrowthStage.ADULT. A herd with neither hears "None of them is elderly or unable to breed." Nothing is sold until they name one, and a name two of them share leads to the which-one question above.

The unmatched case asks; nothing is sold on a guess. A sell whose words match no owned animal's name and no species sells nothing. There is no fallback to the first sellable animal. Speech recognition turns "sell chief" into "sell cheap" and "sell Aries" into "sell earrings", so the game says back the words it heard and offers the two ways to name an animal: "I did not find an animal called cheap in your zoo, so nothing was sold. Say view animals to hear their names, or name a species you keep, such as lion and zebra, for example sell lion", with a "view animals" option. The species read out are the player's own sellable species, up to three, in the resolver's stable order.

  • A species the player keeps no sellable animal of: "You have no tiger to sell, so nothing was sold", then the same offer.
  • A sell that names nothing ("sell", "sell animal", "sell an animal"): "Which animal would you like to sell? Nothing is sold until you name one", then the same offer.
  • A zoo whose only animals are premium, or that has none, hears why nothing can be sold.

Move follows the same rule. Words that match no owned animal's name, no premium display name and no species move nothing: "I did not find an animal called cheap in your zoo, so nothing moved", then "view animals" and the species the player keeps. A move that names no animal ("move", "move to habitat 2") still asks which animal should move.

Bare Yes/No with nothing pending

A bare Alexa Yes/No (FreeTextDeclarationAction.YES / NO) that reaches the zoo wrapper with no matching displayed option is resolved in : a live batch-breed confirmation (pendingBatchBreedSpecies not expired) claims yes/no and routes to ZOO_CONFIRM_BATCH_BREED; a live offer from § When a command is missed takes a yes as its command; otherwise a bare YES gives a friendly clarification ("I am not sure what to say yes to right now...") instead of the old "No game wrapper found for action YES" dead-end, and a bare NO falls through to the baseline acknowledgement.


Utility Classes

ZooAccountNavigator

Source: llitd.utils.zoosimulator.ZooAccountNavigator

Provides streamAnimals(ZooAccount) -- flattens all animals across all zones/habitats into a single stream.

Source:

Post-action processing for the game engine's after-action phase. Recomputes alerts from live ZooAccount state on every response (no cached warning flag), so conditions that no longer hold stop rendering on the next turn.

Alert rules:

Alert Trigger Suppression
Low Zoo Bucks zooBucks < 1000 Always surfaces
Sick animals Any animal with isSick == true across all habitats Suppressed when the current action is ZOO_VIEW_SICK_ANIMALS so the viewer doesn't end with a redundant "Say view sick animals for details" trailer (feedback #574, epic 8421)

The sick-animal count uses the same isSick == true filter as — the alert count must never claim animals exist that the Sick Animals report wouldn't list.

ZooInputSanitizer

Source: llitd.utils.zoosimulator.ZooInputSanitizer

Input validation and sanitization for user commands.

Source:

Command parsing helpers for extracting parameters from user input.