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:
- Strip leading command phrase (
name zone/rename zone/name my zone/set zone name). - Parse leading ordinal (
1/one/first, supports1st/2nd/3rd) — targets that zone by 1-indexed position. - Fallback: if no ordinal, try matching a zone theme (
africa,arctic, etc.) at the start of the remaining text. - 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
performDailyHealthCheckin 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 barebuild attractionwith no type renders the type menu instead of guessing (feedback 5371).- A quantity multiplies a single type:
build 3 ridesbuilds 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 shopbuilds one ride and one shop. The resolver collects every type joined byand/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:
- 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.
- 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.
- 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.
- 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 >= 400ANDanimalWelfareScore >= 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.
- An explicit zone ordinal wins. reads
zone 2,in zone 2,to zone 2, andzone number 2. A number that is not one of the player's zones resolves to nothing. It does not fall through to another zone. - 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. - Otherwise
ZooAccount.currentZoneId, the active zone set byenter zone N, when that zone still exists. - Otherwise, for a move, the zone the moving animal lives in. It is known, not guessed:
move lion to habitat 3reads 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 (). - 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 2andupgrade habitat in zone 2(target a zone directly),view zones/view zone 2, andrename 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 >= 2so single-zone players never hear it. Adds anenter zoneoption. - ** 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 zonesoption. Gated onzoneNumber >= 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:
- Look up the active season (
status = 'active', highestseason_number). INSERT IGNOREthe top 100 by that month's visitors (gen_season_visitorsdesc wheregen_season_monthis the closing season's month and the count is above zero) intoseason_standingskeyed by(season_id, customer_id).RANK()window function over the same order producesfinal_rank. The procedure body isbackend/src/main/resources/db/zoo-season-visitor-rank-and-tiers.sql: it ranksgen_season_visitorsfor the closing month, stores that count infinal_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 writeseason_baseline.UPDATEthe active season tostatus = 'archived'.INSERTan audit row intogame_log.cleanup_audit_snapshotwithcleanup_type='date_based',target_key='zoo_season_rollover_s{N}',stale_rows = standings_count,notes='Season {N} archived; season grants pending Java sweeper'.INSERTa new season covering the current calendar month (DATE_FORMAT(NOW(), '%Y-%m-01')toLAST_DAY(NOW())) withstatus='active'and aprize_pool_jsoncarrying the top three and thetiersarray.INSERTthree 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:
- New enum
PettingZooSpecies(4-6 entries) - New attraction-bound animal subset on PETTING_ZOO attractions
- Simplified breeding pipeline (similar to existing but distinct)
- 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_accountcache (write-behind, 7-day TTL) encodes the account withFieldTolerantKryo5Codec, which matches fields by name, so an account cached before a field was added toZooAccount,ZooZone,HabitatorAnimalstill 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:
- An opening word ("start", "play") starts the zoo.
- 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. - 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 isZooNearestOption: 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 dayZOO_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/expensesZOO_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 shopZOO_FINANCES-- financial overviewSHOP_BUY-- generic buy handlerBASELINE_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:
- 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.
- Otherwise the key picks the animal: an option chosen by its number or by its words.
- 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.