Economy
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Last updated: October 27, 2026
Zoo Simulator -- Economy & Finance Subsystem
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Revenue formulas, cost structures, daily financial cycle, promotions, premium currency, and debt mechanics. All constants extracted from Java source code.
Currencies
Source: ZooAccount,
| Currency | Type | Starting Balance | Uses |
|---|---|---|---|
| Zoo Bucks | Earned | 25,000 (; expansion-model.md A2) | Animals, buildings, staff, enrichments, promotions |
| Golden Tickets | Premium | 5 (starter grant) | Premium animals (16), premium zones (12) |
Starter Promo (resolves 19560 / fb 870)
Two coupled entry-tier offerings: a free 5 Golden Ticket grant on first launch, and a $2.99 ZOO_SIMULATOR_STARTER_PACK ENTITLEMENT covering 200 GT plus a Director's Welcome Kit. The two are designed to work together — the free grant primes the funnel (player has tasted premium currency) and the trial pack converts the primed player into a first purchaser. Both ride the standard / paths.
Free Starter Golden Tickets (5 GT, idempotent per Amazon customer)
Implemented in (called from populateGameDetails). On first launch for a customer the account is granted 5 Golden Tickets via .grantCurrency(userId, "zoo", "GOLDEN_TICKET", 5L, "STARTER_GOLDEN_TICKETS"). A pendingStarterWelcomeLine flag drives a one-shot funnel-aware voice line on the same launch (delivered in welcome flow).
Eligibility gate. Single boolean — ZooAccount.starterGoldenTicketsGranted. Once true, the grant never fires again for that account. The flag survives (Restart Policy preserves premium currency state), so a player who restarts their zoo does NOT re-trigger the grant. The flag is tied to the Amazon customer_id-keyed ZooAccount row, so the only way to re-claim is to create a new Amazon account.
First-launch persistence ordering (fixed 2026-05-28). The grant routes through , which resolves the account via — it requires the ZooAccount to already exist in the live cache. For a brand-new player, populateGameDetails previously built the account in memory via createNewZooAccount but did NOT persist it before the grant ran, so the grant hit "No ZooAccount for customerId X — player must have launched Zoo at least once". The throw was swallowed by grantStarterGoldenTicketsIfFirstLaunch's try/catch, so the request did not visibly crash, but the +5 grant silently failed and brand-new players landed with goldenTickets=0 (plus an ERROR stack in the logs). Fix: the orElseGet branch now calls immediately so the in-flow grant resolves the account from the live cache. Regression: game_test.scenario_tests id 1627 (finances → zooAccount.goldenTickets >= 5) + JUnit .
Anti-abuse rationale. The grant is intentionally small (5 GT = $0.05 of equivalent ISP-pack value at the $1 tier's 100 GT / $1 rate). Farming requires creating fresh Amazon accounts, which costs the attacker more friction than the 5 GT is worth. We deliberately did NOT defer the grant behind a "first day advance" or "first 1,000 ZB spent" milestone because:
- Funnel data (
isp-conversion-review.md/expansion-model.md) shows the bottleneck is awareness + first-purchase friction, NOT free-GT farming. - Deferring the grant kills the welcome-voice-line moment, which is the only spot in the new-player flow that mentions premium currency at all.
- Amazon customer_id is already the natural anti-abuse boundary; the boolean enforces the one-per-customer cap;
purchase_transactionsrow withtransaction_type='GRANT'andproduct_ref='GOODWILL_STARTER_GOLDEN_TICKETS'provides the audit trail.
The original guidance to "hook into ISP funnel (ZOO_BUCKS_SPENT threshold or account age)" was explored and rejected — see "Decision: Why no engagement-milestone gate" below.
Live evidence. 551 starter-GT GRANT rows in purchase_transactions between 2026-05-11 (ship date) and 2026-05-22 (~50/day). Zero documented farming incidents. One STARTER_GOLDEN_TICKETS_REISSUE row exists for a single AdminMessage-driven recovery case (player feedback 5692, AMAY26SP — grant logged but cache-stomped pre-fix).
$3 Trial Pack (ZOO_SIMULATOR_STARTER_PACK — 200 GT + Director's Welcome Kit)
ENTITLEMENT-type ISP product at $2.99 (Amazon's standard ISP price tier closest to $3.00 — exact $3.00 is not an Amazon-allowed price point). Reference name: ZOO_SIMULATOR_STARTER_PACK. Payout: 200 Golden Tickets plus the "Director's Welcome Kit" themed entitlement (premium habitat blueprint + 2 common animal vouchers). First-purchase 2x bonus doubles the GT portion to 400 on first ever purchase.
- Backend enum:
ProductReferenceId.STARTER_PACK(game-prefix-stripped infromReferenceName). - Payout amount:
ProductPackAmounts.STARTER = 200L. - Payout path: → . Null-account fallback routes to with reason
PACK_PAYOUT_FALLBACK_NULL_ACCOUNT. - SMAPI definition: generated by
scripts/alexa/smapi_products.py build_starter_pack_product(). Copy + price + entitlement metadata pulled fromSTARTER_PACKdict +GAME_COPY["zoo_simulator"]["starter_item"]. - VIP point multiplier: 1.17x via (matches $3 tier).
Why STARTER_PACK is the trial-pack vehicle (not a separate "trial pack" SKU). Amazon's ISP system enforces ENTITLEMENT one-time-per-customer at the platform level — re-purchase attempts return "already purchased" without billing. That is the trial-pack property we want (a single-use entry-tier offering with bonus content beyond the equivalent currency-pack); we get it for free by being ENTITLEMENT-typed. Adding a parallel "trial pack" SKU would either need its own one-time-flag scaffolding (we'd be reinventing what Amazon already gives us) or would actually be a consumable masquerading as trial (which is non-compliant — see below).
Alexa ISP Compliance for the Trial Pack
Verified against Alexa In-Skill Products policy (developer.amazon.com/docs/in-skill-purchase/) and the in-house notes at .claude/skills/alexa/isp.md.
| Requirement | How the pack complies |
|---|---|
| Allowed product type for one-time purchase | ENTITLEMENT (SMAPI type: ENTITLEMENT). Amazon enforces one-purchase-per-customer at the buy directive layer — no app-side checks needed. |
| Allowed price point | $2.99 — one of Amazon's published US ISP price tiers ($0.99/$1.99/$2.99/$3.99/$4.99/$9.99/$19.99). $3.00 exact is NOT an allowed Amazon ISP price. |
| Description must NOT contain discount-style language | Current SMAPI copy is "The perfect way to begin your journey... includes 200 Golden Tickets and the Director's Welcome Kit with a premium habitat blueprint and 2 common animal vouchers. Great value for new players looking to get a head start." No "save X%", no "was $Y now $Z", no "limited time" — Amazon ISP policy 4.2/4.3 forbids price-comparison and time-pressure claims. "Great value" is allowed subjective marketing language. |
| First-purchase 2x bonus must be a backend-side mechanic, not a SKU rename | The 2x bonus is applied by based on PurchaseHistory.isEmpty(). The SMAPI product description does NOT advertise the 2x bonus (compliance: Amazon's product description must describe what the customer buys at face value; the bonus is a goodwill in-game mechanic, not part of the listed entitlement). |
| Reference name is immutable post-publish | ZOO_SIMULATOR_STARTER_PACK is locked. Renaming would break SMAPI association, subscriber entitlements, and refund audit. Display copy can be updated via smapi_products.py update --game zoo_simulator without resubmission. |
| Tax + linking | Tax interview is pre-completed at the vendor level (covers all 5 currency-using games). Link state survives between cert cycles — verify with smapi_products.py audit --game zoo_simulator. |
| Locale parity | Product must publish to every locale the skill publishes to (en-US, en-CA, en-IN, en-AU, en-GB per zoo-simulator/manifest-development.json). _build_locale_entries writes all five from the same source copy. |
| AUTO_PUBLISHING re-cert | Any change to SMAPI product copy triggers a fresh AUTO_PUBLISHING cycle on the next interaction-model push. ISP-only copy changes do NOT require a separate smapi_publish.py submit. |
Open verification step. No live ZOO_SIMULATOR_STARTER_PACK purchase has been observed in purchase_transactions (live currency-pack purchases for zoo break down as: 3 SUBSCRIPTION, 1 ONE_DOLLAR_PACK, 1 THREE_DOLLAR_PACK). The pack is present in the SMAPI catalog and linked to the skill (per cross-game-audit-2026-04-20.md lineup verification), but discoverability is the gap — there is no buy-page surface that promotes it. Out of scope for this task; a follow-up should add a "Starter Pack" mention to on the first-purchase branch.
Combined Starter Promo Flow
Launch 1 (new Amazon customer)
-> .populateGameDetails creates ZooAccount
-> grantStarterGoldenTicketsIfFirstLaunch fires:
grants 5 GT (cache-write, audit row)
sets starterGoldenTicketsGranted=true
sets pendingStarterWelcomeLine=true
-> Welcome flow reads:
"Welcome to your zoo, Director. It is empty and ready to build. Say build
zone to start, then add a habitat and buy your first animal. Your 5 golden
tickets will buy your first white tiger once it has a habitat," with
leaderboard and skin details deferred to help and status. The five starter
tickets buy a first rare animal once per account: index.md § Premium Content. The line leads with the first action rather than an economy lecture so a new director is not lost on turn one.
Launch 2+ (same customer)
-> grantStarterGoldenTicketsIfFirstLaunch sees flag, returns immediately
-> No second grant, no second welcome line
Player engages, reaches premium-shop or buy-page surface
-> isFirstPurchase branch in showPackListing surfaces Director's Welcome Pack 2x bonus
-> Player buys ZOO_SIMULATOR_STARTER_PACK ($2.99)
-> Amazon billing flow (ENTITLEMENT, one-time-per-customer)
-> Connections.Response -> payoutProducts:
200 GT * 2 (first-purchase bonus) = 400 GT
Director's Welcome Kit entitlement (premium habitat blueprint + 2 vouchers)
-> Voice line: "First purchase bonus! You received double Golden
Tickets, 400 total. The animals are thrilled to meet
you, Director."
Player restarts zoo (resetZoo)
-> goldenTickets and starterGoldenTicketsGranted preserved per Restart Policy
-> Free grant does NOT re-fire
-> Trial pack remains marked owned by Amazon (ENTITLEMENT survives)
Decision: Why no engagement-milestone gate
Guidance from 19560 suggested gating the free 5 GT on a ZOO_BUCKS_SPENT threshold or account age to deter farming. Three reasons we kept the immediate-on-first-launch design:
- Grant size is below farming break-even. 5 GT is worth approximately $0.05 at the $1-pack rate. Creating an Amazon account, downloading the Alexa app, linking the skill, and triggering a launch costs the attacker more than $0.05 of time. There is no observed farming pattern in the 551 grants since 2026-05-11.
- Welcome line is the funnel's only first-launch GT mention. Tutorial step 5 mentions premium tickets exist but does not give a balance to anchor against. Deferring the grant past the welcome line breaks the funnel-awareness coupling (see
isp-conversion-review.mdRec 1 / Rec 7). - Customer-id idempotency is already the strongest anti-abuse signal we have. Amazon's customer_id is stable across launches and survives account restart. The
starterGoldenTicketsGrantedboolean tied to that id is the canonical gate.
If farming evidence ever surfaces (e.g., a spike of STARTER_GOLDEN_TICKETS GRANT rows from clustered customer_ids in a short window), the gate can be tightened by inserting a check on ZooAccount.createdDate age before the grant fires. The hook point is the top of grantStarterGoldenTicketsIfFirstLaunch. No schema migration needed.
Standard Animal Purchase & Sale Pricing
Source: , ,
Purchase cost — per species
Buying a standard animal costs that species' zoo_simulator.species_defs.base_value, not a flat amount. The spread runs 250 to 5,000 Zoo Bucks, giving the player the budgeting choice the backlog asked for (player feedback 7373/7374): 5,000 ZB buys one Tiger, or twenty Squirrels, or any mix in between. A bulk buy is N of one species, so the total is base_value * count. The per-animal price is stamped on the Animal.purchasePrice field at purchase time and is what the sell path refunds against.
| Buy cost (Zoo Bucks) | Species |
|---|---|
| 250 | Squirrel |
| 300 | Opossum |
| 400 | Raccoon |
| 450 | Skunk |
| 1,000 | Parrot, Snake, Turtle |
| 1,500 | Fox, Owl |
| 1,800 | Flamingo, Otter |
| 2,000 | Monkey, Wolf, Deer, Eagle, Penguin, Seal |
| 2,500 | Zebra, Kangaroo, Crocodile |
| 3,500 | Dolphin, Shark |
| 4,000 | Lion, Giraffe, Bear |
| 4,500 | Polar Bear, Gorilla |
| 5,000 | Tiger, Elephant, Panda |
Values are designer-tuned in species_defs.base_value (Caffeine-cached, 1-hour TTL) — no code deploy to rebalance. Before the buy path charged a flat ANIMAL_BASE_PRICE = 1000 and ignored base_value; base_value was consumed only by calculateAnimalValue for appraisal. The flat constant is gone.
Spoken priced species listing
viewAvailableSpecies and the buy-animal miss path speak a data-driven, voice-friendly listing grouped into three price bands: budget below 2,500, mid-range 2,500 to 3,999, premium 4,000 and up. Each band opens with its real Zoo Buck range pulled from species_defs, so a species added to the table appears in the listing automatically with the correct price and no copy edit. The opening "Animals cost between X and Y" line reads the live minimum and maximum base values. Band cut points live in as STANDARD_BAND_FLOOR and PREMIUM_BAND_FLOOR.
Sell-back — 50% of purchase price, species-relative floor
Source:
Selling a standard animal refunds 50% of its stored Animal.purchasePrice, floored at the lesser of 500 Zoo Bucks and half the species' base_value (). Because the refund is pegged to what the player actually paid, the buy-then-sell round-trip is a flat 50% haircut for every species: a Tiger bought for 5,000 sells for 2,500, a Parrot bought for 1,000 sells for 500, a Squirrel bought for 250 sells for 125. No species can be bought low and resold for more than half its cost (no buy-low-sell-high loop), and no premium-base species suffers a punishing near-total loss. The floor is species-relative rather than a flat 500 specifically because the starter tier introduced species priced under one thousand Zoo Bucks: a flat 500 floor would refund 500 for a 250 Squirrel — an instant doubling. For every species priced 1,000 or more the floor is still exactly 500 (half the base value already meets or exceeds it), so the established economy is unchanged; only the sub-1,000 starter species drop below. Bred offspring carry no purchasePrice, so they sell at this species floor (500 for the legacy tier, 125 to 225 for starter species), keeping per-pair breeding ceilings (animals.md) sane for cheap species. Premium animals bought with Golden Tickets are not sellable through this flow. The sell path does NOT pay out the animal's computed marketValue (which factors age, personality, and stats and can exceed base_value on a fresh animal) — paying that would re-open a buy-low-sell-high loop.
Sell-by-name targeting and the "which one?" prompt (fb 8762 / 8734)
resolves the animal to sell by name first, so "sell tiger Bella" / "sell Bella" removes the specific named animal and never a same-species animal the player wanted to keep. The name is matched from either the spoken phrase or an option-select's matched key, through the one name resolver; what a name matches, and the "which one?" question when several animals share it, is management.md § Which animal a name means. The free-text path is opened by the parameterized sell {target} / sell animal {target} NLU route (process_one_shot_values rows 5559/5560, FREE_FORM → SHOP_SELL); before it existed, "sell tiger Bella" fell through to NLU fallback and only bare "sell" routed.
When the player names a species for which they own two or more sellable (non-premium) animals but names no specific animal ("sell tiger" with three tigers), the handler does NOT silently dump the first by iteration order — it asks "which one?", lists each animal by name, and offers a per-animal "sell <name>" option, selling nothing that turn (sellableOfSpecies + promptWhichAnimalToSell). This stops players losing generational animals they meant to keep. A species with exactly one sellable animal sells unambiguously; premium animals remain excluded from the whole flow (they are paid in Golden Tickets). Words that name no owned animal and no species sell nothing and are said back to the player; that rule is management.md § Sell resolution.
Habitat theme match — soft happiness, appeal, and value bonus (feedback 7354)
Placing an animal in a habitat whose type matches its preferred theme grants a one-time +10 happiness (). Both calculateAnimalValue and calculateVisitorAppeal fold health+happiness into a 0.5–1.5 statsMultiplier, so the happiness lift measurably raises the animal's market value and visitor appeal — appeal in turn feeds visitor morale and daily ticket revenue (simulation.md Morale Mechanics). Theme is a soft bonus, not a gate: any animal can be placed in any standard habitat, and an off-theme placement is allowed with no penalty (it simply forgoes the bonus). The sell floor still pegs to purchasePrice, so the happiness-driven marketValue lift does not create a sell-back exploit. Full model + premium-animal handling: animals.md Habitat Theme Bonus.
Daily Revenue
Visitor Revenue
Source: ,
Per visitor:
spendingCapacity = random(5, 20) Zoo Bucks
moraleMultiplier = f(morale) (see Morale-Based Spending below)
totalSpent = round(spendingCapacity * moraleMultiplier)
Visitor spending occurs during calculateVisitorSpending():
- Base ticket revenue from habitat visits
- Additional revenue from attraction visits
Morale-Based Spending Multiplier
Source:
if morale < 40:
multiplier = 0.5 + (morale / 80.0) // range: 0.5 to 1.0
else if morale < 60:
multiplier = 0.75 + ((morale - 40) / 80.0) // range: 0.75 to 1.0
else:
multiplier = 1.0 + ((morale - 60) / 80.0) // range: 1.0 to 1.5
| Morale | Multiplier Range | Effect |
|---|---|---|
| 0 | 0.50x | Half spending |
| 20 | 0.75x | Reduced |
| 40 | 0.75x-1.0x | Moderate |
| 60 | 1.0x | Normal |
| 80 | 1.25x | Good |
| 100 | 1.5x | Maximum |
Attraction Revenue
Source:
revenue = round(attraction.revenuePerVisitor * moraleMultiplier)
Same morale multiplier formula as visitor spending above.
Base revenue per visitor by attraction type:
| Attraction Type | Revenue/Visitor | Build Cost | Capacity | Appeal Rating |
|---|---|---|---|---|
| VIEWING_PLATFORM | 5 | 1,500 | 30 | 40 |
| PLAYGROUND | 8 | 1,500 | 40 | 50 |
| SNACK_STAND | 10 | 1,200 | 35 | 35 |
| PETTING_ZOO | 12 | 3,500 | 25 | 65 |
| AVIARY | 14 | 3,500 | 45 | 60 |
| SHOP | 15 | 2,000 | 50 | 45 |
| AMPHITHEATER | 18 | 4,500 | 100 | 70 |
| RESTAURANT | 20 | 3,000 | 60 | 55 |
| AQUARIUM | 22 | 6,000 | 70 | 80 |
| RIDE | 25 | 5,000 | 80 | 75 |
Attraction Synergy Bonus
Source:
When multiple attractions exist in the same zone:
synergyMultiplier = 1.0 + min(0.3, (attractionCount - 1) * 0.1)
| Attractions in Zone | Synergy Multiplier |
|---|---|
| 1 | 1.0x |
| 2 | 1.1x |
| 3 | 1.2x |
| 4+ | 1.3x (cap) |
Attraction Diversity Bonus
if uniqueTypes >= 3: diversityBonus = 1.15
else if uniqueTypes >= 2: diversityBonus = 1.08
else: diversityBonus = 1.0
Combined zone bonus: synergyMultiplier * diversityBonus
Maximum: 1.3 * 1.15 = 1.495x
Attraction Visit Morale Boost
Source:
moraleBoost = min(5, attraction.appealRating / 20) // 0-5 points
Subscriber Revenue Bonus
Source: ,
totalRevenue = isSubscriber ? (long)(baseRevenue * 1.25) : baseRevenue
Subscribers receive a 25% revenue bonus on all daily visitor revenue. Independently, a guild
tier-3 perk (, +10%, see )
can also raise totalRevenue above baseRevenue for a player who is NOT a subscriber.
Non-subscriber bonus mislabeling (report group 261 / , fixed). The day-summary
revenue line derives its itemized bonus purely from totalRevenue - baseRevenue. Before the fix
that entire gap was always spoken as "Subscriber bonus, 25 percent extra", so a non-subscriber with
the guild tier-3 perk heard a subscriber-only entitlement they do not have — in the same summary as
the "you missed out on the subscriber bonus" nag (SUBSCRIBE_NAG_MIN_MISSED_BONUS), a directly
contradictory pair of lines. Fix: now labels the gap as
"Subscriber bonus" only when result.isSubscriber() is true; a non-subscriber's gap (guild-perk-only)
is spoken as "Guild revenue bonus" instead, so the headline total still reconciles with the itemized
breakdown (pt-08 invariant) but the wording is never attributed to an entitlement the player lacks.
Guarded by JUnit
(chosen over a scenario_test because seeding a guild tier-3 perk has no setup_json hook — the same
HARNESS_LIMITATION precedent as scenario_tests 2195/2196 for other guild-state behavior).
Daily Expenses
Facility Upkeep
Source:
Current vs future model (reconciliation): This flat upkeep is the model the code runs today (). The per-tier upkeep curve in expansion-model.md § Per-Tier Multipliers is design-locked backlog (Phase 3/5), not yet implemented — when that phase ships, the two must be reconciled to one canonical formula.
| Category | Cost | Notes |
|---|---|---|
| Zone base upkeep | 150/zone/day | Per zone |
| Habitat base upkeep | 100/habitat/day | Per habitat |
| Per-animal upkeep | 30/animal/day | Per animal in habitat |
| Attraction upkeep | 100/attraction/day | Per attraction |
| Premium zone multiplier | 1.5x | Applies to zone base upkeep only |
Minimum daily overhead: Every zoo pays a flat 100 Zoo Bucks/day base operating cost regardless of size (.MINIMUM_DAILY_OVERHEAD). An empty zoo with zero zones still calculates upkeep = 100 ZB/day, so a brand-new account loses 100 ZB on the first advance-day even before building anything. Surplus paid out of starting capital.
Habitat upkeep formula:
habitatUpkeep = 100 + (animalCount * 30)
Zone upkeep formula:
zoneUpkeep = 150 * (isPremium ? 1.5 : 1.0)
+ sum(habitatUpkeep for each habitat)
+ (attractionCount * 100)
Staff Salaries
Source:
| Staff Type | Daily Salary | Hiring Fee | Capacity |
|---|---|---|---|
| Veterinarian | 250/day | 5,000 one-time | Auto-treats 8 sick animals/day |
| Maintenance | 150/day | 3,000 one-time | Prevents breakdowns |
No staff cap -- unlimited hiring allowed. A single hire command clamps to MAX_STAFF_HIRE_COUNT in (see § Multi-Quantity Purchase Ergonomics) so one voice command cannot loop unbounded; repeat the command for more.
Vet throughput vs salary (balance note): each vet auto-treats up to 8 sick animals/day (raised from 5 to cut the manual treat-sick micromanagement players reported). Salary stays 250/day because the change is self-balancing on the treasury — higher throughput means the vet auto-spends MORE Zoo Bucks per day (up to 8 treatments at 500 / 1,500 / 3,000 each, by MILD / MODERATE / SEVERE), converting player time into Zoo Bucks at the same wage rather than into a free win. Illness frequency is unchanged: preventative coverage stays at 5 animals/vet (the vets * 5 capacity ratio that drives the up-to-0.7x illness-chance reduction), so players still scale vet headcount with herd size to keep animals from getting sick. Decoupling reactive throughput (8) from preventative coverage (5) is intentional.
Staff cost formula:
dailyStaffCost = (veterinarians * 250) + (maintenanceStaff * 150)
Total Daily Expenses
totalExpenses = facilityUpkeep + staffSalaries + eventExpenses
Event expenses come from random daily events (see Simulation subsystem).
Ticket Pricing
Source:
Player-set admission ticket price is enforced to a fixed range. Values outside this range surface a voice retry with the bounds spelled out.
| Bound | Value | Constant |
|---|---|---|
| Minimum ticket price | 5 ZB | MIN_TICKET_PRICE (local in setTicketPrice) |
| Maximum ticket price | 50 ZB | MAX_TICKET_PRICE (local in setTicketPrice) |
| Default if no number parsed | 10 ZB | Inline fallback when possibleNumbers + optionalNumber + regex all empty |
The handler accepts a number from three sources in priority order: possibleNumbers (populated by the NUMBER value_type NLU slot), storyKeySelected.optionalNumber, and a regex fallback over originalText then matchedKey (handles 'set ticket price 25').
Bankruptcy Thresholds
Source: (class-level constants),
Bankruptcy is checked at the end of advanceDay after debt accumulation. Either threshold being met ends the game and routes through (see Restart Policy below).
| Threshold | Value | Constant |
|---|---|---|
| Max consecutive days in debt | 7 | MAX_DAYS_IN_DEBT |
| Max total debt allowed (ZB) | 10,000 | MAX_DEBT_ALLOWED |
When either trigger fires the player sees the Game Over voice script (with whichever clause applies: 'in debt for N days' or 'debt of N is insurmountable'), final stats, and a start new zoo option. Golden Tickets and ISP entitlements survive the reset per Restart Policy.
Debt System
Source:
Debt Accumulation
if zooBucks >= totalExpenses:
// Pay expenses, surplus goes to debt repayment
surplus = zooBucks - totalExpenses
if existingDebt > 0:
debtPayment = min(surplus, existingDebt)
debt -= debtPayment
if debt <= 0:
debt = 0
daysInDebt = 0 // RESET on full repayment
zooBucks = surplus - debtPayment
else:
// Cannot afford -- go into debt
actualPaid = zooBucks // pay what we can
debtAccrued = totalExpenses - zooBucks
debt += debtAccrued
daysInDebt += 1
zooBucks = 0
Debt Visitor Penalty
Source:
DEBT_VISITOR_PENALTY_PER_DAY = 0.20 (20% per day in debt)
DEBT_VISITOR_PENALTY_MAX = 0.60 (60% maximum reduction)
debtPenalty = min(daysInDebt * 0.20, 0.60)
reducedVisitors = round(visitorCount * (1.0 - debtPenalty))
| Days in Debt | Visitor Reduction |
|---|---|
| 0 | 0% |
| 1 | -20% |
| 2 | -40% |
| 3+ | -60% (cap) |
Transaction System
Source:
Transaction Types
| Type | Trigger |
|---|---|
| VISITOR_REVENUE | Daily revenue credit |
| ANIMAL_PURCHASE | Buying animals |
| HABITAT_CONSTRUCTION | Building habitats |
| ATTRACTION_CONSTRUCTION | Building attractions |
| PROMOTION_COST | Activating promotions |
| MAINTENANCE | Default/generic |
| GOLDEN_TICKET_PURCHASE | Adding premium currency |
| PREMIUM_PURCHASE | Spending golden tickets |
| SEASON_UNLOCK | Spending Zoo Bucks or golden tickets on a season unlock. Never refunded by a restart. Written only for a season with themed content; a monthly season without it is not charged (expansion-model.md § Phase 3 status) |
Type determination for deductions is string-based on the reason parameter:
if reason contains "animal" or "purchase": ANIMAL_PURCHASE
if reason contains "habitat" or "construction": HABITAT_CONSTRUCTION
if reason contains "attraction": ATTRACTION_CONSTRUCTION
if reason contains "promotion": PROMOTION_COST
else: MAINTENANCE
Transaction Ledger Storage — recent window + durable side table
Source: = 25; zoo_simulator.zoo_transactions;
The full financial ledger lives in the durable zoo_simulator.zoo_transactions side table (one row per transaction, keyed by a UUID transaction_id). The in-blob ZooAccount.transactionHistory list is only a small recent window, capped at 25 entries: when a transaction is appended via appendTransaction(), the oldest entries are trimmed from the head, and the transaction is also staged on the transient ZooAccount.pendingLedgerWrites buffer. drains that buffer to the side table in one batched, idempotent (INSERT IGNORE), fail-safe write — it can never break a gameplay save, and it skips synthetic test-traffic accounts.
Why this exists (fb 8762; supersedes the in-blob 200 cap): the transactionHistory list serialises into BOTH the Redis-cached account (Kryo) and the MySQL account_data JSON on every save. A 200-entry history was ~43KB — about 87% of a heavy account's ~50KB blob — so the full ZooAccount (de)serialisation cost dominated every Alexa turn. On the most-engaged accounts this pushed advance-day's load-mutate-save round-trip past Alexa's ~8s response window (or threw a Redis save exception), so the fully-built daily summary never reached the device and the player heard silence on "advance day" (fb 8762 + cluster). Earlier history: a player once hit 1,993 entries / 460KB ( first capped it at 200). Lowering the in-blob window to 25 and moving the durable copy to the side table shrinks the hot-path payload ~90% on the history portion.
Nothing player-facing reads the ledger (no caller of getRecentTransactions); it is audit/analytics only. The voluntary-restart refund is NOT bounded by the window — premium Golden Ticket spend is tracked in the cap-independent currentCycleLayoutGoldenTicketsSpent accumulator (see § Refund computation), so trimming the window never affects refunds. A one-time backfill migrates existing in-blob histories into the side table post-deploy (the repository root's scripts/maintenance/zoo_transactions_backfill.py).
A simulated day writes a fixed number of rows, whatever the zoo's size. Upkeep is one Daily expenses row and the vets' treatments are one Vet treatments for N animals row
(, typed MAINTENANCE), so the table grows with player-days and
commands, not with animals. A row per treated animal wrote over a million rows a day for one zoo; the
day's total is the breakdown the ledger needs. The retention statement is
design/data/schemas/zoo-simulator.md § zoo_transactions.
Animal ledger
Money rows do not name an animal. Every arrival and every removal writes zoo_simulator.zoo_animal_events with the animal id, the species (the premium display name when the animal has one), and a reason: death, sale, move out, move in, restart, birth, or purchase. A move writes both sides, so the zoo-wide count does not change. Restart writes one removal for each animal still placed, before the zones are cleared.
The account keeps animalLedgerCheckpoint (the live count at the last reconcile that matched) and animalLedgerNet (arrivals minus recorded removals since then). The live count is the animal map, including dormant zones, not the habitat occupancy counter. The first pass of the existing daily job () baselines a null checkpoint and raises nothing. After that, the count must equal the checkpoint plus the net. An unexplained difference does not slide the checkpoint, and ZooAnimalLedgerHealth files one Admin Health finding (claude.system_finding, class zoo-animal-ledger-mismatch) until the gap changes or a later day matches.
Login Rewards
Source: (via login-reward dispatch)
Three cadences pay Zoo Bucks + Golden Tickets on game entry. Daily fires once per UTC day, monthly on calendar-month boundary, holiday on configured calendar days.
| Cadence | Zoo Bucks | Golden Tickets | EarnedType |
|---|---|---|---|
| Daily | 1,000 | 1 | BASIC_CURRENCY / PREMIUM_CURRENCY |
| Monthly | 4,000 | 4 | BASIC_CURRENCY / PREMIUM_CURRENCY |
| Holiday | 8,000 | 8 | BASIC_CURRENCY / PREMIUM_CURRENCY |
Payouts ride the standard login-reward path; players see the grant in their game-entry voice line. Holiday cadence stacks with daily if both fire on the same launch (separate RewardPayout rows).
Promotion System
Source: , llitd.model.zoosimulator.Promotion
Promotion Types
PromotionType enum: DISCOUNT, SPECIAL_EVENT, ADVERTISING, SEASONAL
Promotion Properties
| Property | Type | Range | Notes |
|---|---|---|---|
| visitorMultiplier | Double | 1.0-1.5 | Max taken across active promotions |
| spendingMultiplier | Double | 1.0-1.2 | Max taken across active promotions |
| moraleBonus | Integer | 0-10 | Max taken across active promotions |
| startDate | LocalDateTime | - | Auto-set to now if in the past |
| endDate | LocalDateTime | - | Must be after startDate |
| cost | Long | > 0 | Deducted on activation |
Multiple Promotions Rule
Source: , getSpendingMultiplier, getMoraleBonus
All three effects use maximum value (not additive) across active promotions:
visitorMultiplier = activePromotions.stream()
.mapToDouble(Promotion::getVisitorMultiplier)
.max().orElse(1.0)
Promotion Lifecycle
- Activation:
activatePromotion()validates dates, deducts cost, adds toactivePromotions - Expiry check:
removeExpiredPromotions()removes promotions whereendDate.isBefore(now) - Auto-cleanup: Expired promotions removed in Phase 7 of daily simulation
Multi-Quantity Purchase Ergonomics
Voice players can buy or build several items in one command. Pricing is strictly linear — N times an item is exactly N times the price, no bulk discount — so the locked cost curve is unchanged; only the number of turns shrinks. Each command has a cap, sized so a large zoo is not made to repeat itself while one turn stays inside its voice time: MAX_PURCHASE_COUNT in ZooPurchaseValidator for animals, MAX_STAFF_HIRE_COUNT in for staff, MAX_PROMOTIONS in , and for habitats and attractions alike, which rises with the zoo score so a quiet zoo builds ten at once and a large one far more. The code holds the numbers; where this line and the code differ, the code is right. A capped build says how many it built and what raises the cap.
- Multi-buy count: the number of animals is the first number the player said once the number
after "habitat" or "zone", digits or words, is taken out (
ZooPurchaseValidator.extractCountFromKey). "Buy lions in habitat 20" is one lion for habitat 20, and "buy animal lion habitat 2" is one lion; speech recognition hears "buy two lions" as "buy to lions", which left the habitat number as the only number and bought twenty. - Multi-build (fb 7466):
build 3 grassland habitats/build 2 ridesloop the single-build path N times (, ). Thezone Nphrase is stripped before the count is read sobuild habitat savanna in zone 2stays one build. Running out of Zoo Bucks mid-loop keeps what was built and reports the partial. - Bulk-buy spillover (fb 8641): a buy larger than one habitat's free space spreads across every habitat with room, theme-compatible first (
ZooHabitatSelector.planPlacement). The player is charged only for animals that find a home; overflow is reported, never billed. - Multi-promotion (fb 8630): because promotion effects stack by MAX (above), activating N identical concurrent promotions would charge N times for the same daily boost — a trap. Instead
create promotion <type> Nbuys one promotion at N times the base duration for N times the base cost. Same per-day multipliers (no balance change), a proportionally longer run.
Backlog: bulk-discount and per-species cap visibility
Playtest . Surface bulk pricing and per-species purchase caps in the animal-purchase flow so a player hears the cap and any volume price before they buy, rather than discovering them only on a refused utterance. Pricing stays linear until a later design pass changes the cost curve. This item lives here, not in a retired agent_queue row.
Construction Costs
Source: + (DB-backed, Caffeine-cached)
Construction balance values live in the zoo_simulator schema and are loaded into a 1-hour in-process Caffeine cache so rebalances no longer require a code deploy. delegates every per-type lookup to ; seed rows match the original switch values.
| Table | Columns | Rows |
|---|---|---|
zoo_simulator.zone_theme_defs |
theme (PK, enum), construction_cost |
10 (one per ZoneTheme) |
zoo_simulator.habitat_type_defs |
habitat_type (PK, enum), construction_cost, initial_capacity |
11 (one per HabitatType) |
zoo_simulator.attraction_type_defs |
attraction_type (PK, enum), construction_cost, initial_capacity, revenue_per_visitor, appeal_rating |
10 (one per AttractionType) |
Rebalance workflow: UPDATE the relevant row, then wait for the Caffeine TTL (1 hour) or call to apply immediately. Adding a new enum value requires (a) an ALTER TABLE to extend the ENUM and (b) a seed row — the service throws on unknown types.
Zone Construction Costs
| Zone Theme | Cost (Zoo Bucks) |
|---|---|
| NORTH_AMERICA, EUROPE | 5,000 |
| SOUTH_AMERICA, ASIA | 6,000 |
| AFRICA, AUSTRALIA | 7,000 |
| ARCTIC, DESERT | 8,000 |
| RAINFOREST | 9,000 |
| AQUARIUM | 10,000 |
New zones start with: designRating = 50, empty habitats/attractions maps.
Habitat Construction Costs
| Habitat Type | Cost | Initial Capacity |
|---|---|---|
| GRASSLAND, WETLAND | 2,000 | 8, 7 |
| SAVANNA, FOREST | 2,500 | 8, 6 |
| JUNGLE, MOUNTAIN, HATCHERY | 3,000 | 6, 5, 6 |
| NURSERY | 3,000 | 10 |
| DESERT, ARCTIC | 3,500 | 5, 5 |
| AQUATIC | 4,000 | 10 |
New habitats start with: qualityRating = 60, upgradeLevel = 0.
Habitat Upgrade Costs
Source:
upgradeCost = baseCost * 0.5 * pow(1.5, currentLevel)
Max upgrade level: 5. Benefits per upgrade: +2 capacity, +10 quality rating (cap 100).
| Level | Cost Multiplier | Example (base=2500) |
|---|---|---|
| 0 -> 1 | 0.5x | 1,250 |
| 1 -> 2 | 0.75x | 1,875 |
| 2 -> 3 | 1.125x | 2,813 |
| 3 -> 4 | 1.6875x | 4,219 |
| 4 -> 5 | 2.53x | 6,328 |
Attraction Upgrade Costs
Source:
upgradeCost = baseCost * 0.6 * pow(1.6, currentLevel)
Max upgrade level: 5. Benefits per upgrade: +20 capacity, +5 revenue/visitor, +8 appeal (cap 100).
| Level | Cost Multiplier | Example (base=3000) |
|---|---|---|
| 0 -> 1 | 0.6x | 1,800 |
| 1 -> 2 | 0.96x | 2,880 |
| 2 -> 3 | 1.536x | 4,608 |
| 3 -> 4 | 2.458x | 7,373 |
| 4 -> 5 | 3.932x | 11,796 |
Premium Currency (Golden Tickets)
Source: ,
Premium Animal Costs
| Tier | Animals | GT Cost | Appeal Bonus |
|---|---|---|---|
| RARE (1.5x) | White Tiger, Black Panther | 50 | 750 |
| RARE (1.5x) | Albino Peacock | 40 | 600 |
| RARE (1.5x) | Golden Eagle | 45 | 650 |
| EPIC (2.0x) | Philippine Eagle | 70 | 850 |
| EPIC (2.0x) | Snow Leopard | 75 | 900 |
| EPIC (2.0x) | Giant Panda | 80 | 1,000 |
| EPIC (2.0x) | Sumatran Rhino | 85 | 1,100 |
| LEGENDARY (3.0x) | Quagga | 90 | 1,200 |
| LEGENDARY (3.0x) | Dodo Bird | 100 | 1,300 |
| LEGENDARY (3.0x) | Saber-Tooth Tiger | 120 | 1,500 |
| LEGENDARY (3.0x) | Woolly Mammoth | 150 | 1,700 |
| MYTHICAL (5.0x) | Unicorn | 200 | 2,000 |
| MYTHICAL (5.0x) | Griffin | 220 | 2,100 |
| MYTHICAL (5.0x) | Phoenix | 250 | 2,300 |
| MYTHICAL (5.0x) | Dragon Hatchling | 300 | 2,600 |
Premium animals are placed in the first habitat with capacity. They use a base species compatible with the habitat type:
| Habitat Type | Base Species Used |
|---|---|
| GRASSLAND | LION |
| SAVANNA | GIRAFFE |
| FOREST | BEAR |
| JUNGLE | TIGER |
| WETLAND | CROCODILE |
| MOUNTAIN | BEAR |
| DESERT | SNAKE |
| ARCTIC | PENGUIN |
| AQUATIC | DOLPHIN |
Premium Animal Purchase Validation Order (, epic 19902)
validates in this fixed order:
- Recognize the animal — an unrecognized name returns the available-animals list.
- Golden Ticket affordability — when the player cannot afford the animal, surface the shortfall message plus the Golden Ticket upsell via
ZooInsufficientFundsRenderer.renderGoldenTickets. This is the BUY_PAGE_VIEWED to ISP_TRIGGERED conversion step. - Habitat availability — reached only once the player can afford the animal. If no habitat has space, prompt to build one.
- Placement and Golden Ticket deduction.
The affordability check MUST run before the habitat gate. Pre-fix () the habitat-availability error preempted the affordability check, so a new player with 5 starter Golden Tickets and no habitats hit "build a habitat first" and never saw the upsell — the funnel never reached ISP.
Premium Shop Voice Output ()
The premium-shop browse screens — viewPremiumShop, viewPremiumAnimals, viewPremiumZones — follow the voice-first output rules: no parentheses, brackets, or slashes. Rarity and ticket cost read as plain sentence fragments, for example "White Tiger, rare, 50 tickets" rather than "White Tiger [RARE] (50 tickets)". The three browse methods set skipReprompt so the zoo-management after-action context options are not appended on top of each screen's curated option list, keeping the spoken option count within the 7-option voice cap.
Premium Zone Costs
| Category | Zone | GT Cost | Appeal Multiplier |
|---|---|---|---|
| Historical | Prehistoric Era | 75 | 2.0x |
| Historical | Ice Age | 75 | 2.0x |
| Historical | Ancient Egypt | 100 | 2.2x |
| Fantasy | Enchanted Forest | 150 | 2.5x |
| Fantasy | Underwater Kingdom | 180 | 2.8x |
| Fantasy | Dragon Sanctuary | 200 | 3.0x |
| Sci-Fi | Genetic Lab | 150 | 2.5x |
| Sci-Fi | Alien Biosphere | 175 | 2.7x |
| Sci-Fi | Space Habitat | 200 | 3.0x |
| Extreme | Cloud Forest | 90 | 2.1x |
| Extreme | Deep Ocean Trench | 100 | 2.3x |
| Extreme | Volcanic Island | 110 | 2.4x |
Premium zones start with: designRating = 95, isPremium = true, no standard theme (uses customThemeName).
Restart Policy
Canonical source: design/games/zoo-simulator/expansion-model.md § Restart as Cosmetic Re-allocation. Phase 1 of the expansion-model shipped 2026-05-26 and split the single resetZoo path into two distinct paths gated by RestartReason. The summary below is normative for the economy subsystem; the canonical doc has the full audit context, persistence list, and UX flow.
Two paths — VOLUNTARY vs BANKRUPTCY
.resetZoo(account, reason) branches on RestartReason:
| Path | Entry point | Zoo Bucks result | Golden Tickets result |
|---|---|---|---|
VOLUNTARY |
(player says "restart zoo" then "yes restart") | The balance held, floored at 25,000 ZB. No Zoo Bucks spending is refunded: the balance already holds what every sale returned, so a refund on top would let buy, sell and restart mint the sale price each loop | Refund of 100% GT spent on premium animals + premium zones, added to current balance (current 100 GT + refund 50 GT = 150 GT) |
BANKRUPTCY |
when daysInDebt >= 7 OR debt >= 10,000 ZB |
Reset to 25,000 ZB floor only — no refund of spent | Current balance preserved unchanged — spent GT NOT refunded |
The split exists so a player cannot intentionally bankrupt to harvest a free GT refund. Audit finding A3 in expansion-model.md.
Refund computation
returns the Golden Tickets spent on PREMIUM_PURCHASE (premium animals and premium zones) in the current build cycle. A premium animal is never sold, so the refund returns only what the restart removes. No Zoo Bucks are refunded: the restart keeps the balance, which already carries every Zoo Buck a sale returned, and the day's expenses and staff hires were spent, not placed.
The authoritative source is the cap-independent accumulator currentCycleLayoutGoldenTicketsSpent, incremented at deduction time by appendTransaction and zeroed by resetZoo at the start of a new cycle, so it is not bounded by MAX_TRANSACTION_HISTORY. The transactionHistory scan, scoped by lastRestartedAt, covers accounts created before the accumulator landed; the larger of the two is returned, and both are bounded above by the true cycle spend. currentCycleLayoutZooBucksSpent is retired and read by nothing; it stays on ZooAccount only until the cached zoo_account entries written with it have expired (FieldTolerantKryo5Codec cannot read an entry written before a field was removed).
What persists (both paths)
Lifetime aggregates + identity flags survive both VOLUNTARY and BANKRUPTCY paths:
highestZooScore(lifetime best)totalVisitors,lifetimeRevenue(lifetime aggregates)totalAnimalsBorn,rareVariantsDiscovered,highestGeneration(breeding aggregates)totalBehaviorsTrained,totalEnrichmentsPurchased(training aggregates)starterGoldenTicketsGranted(one-time-per-customer idempotency for the +5 GT welcome — audit A5)createdDate,lastPlayedDate(identity timestamps)transactionHistory(player's ledger; new entries filtered bylastRestartedAt)skillCustomerId,accountRef,zooName(identity)game_log.purchase_transactionsrows (separate table — ISP audit trail untouched)- ISP entitlements (Amazon-side,
ZOO_SIMULATOR_STARTER_PACKpermanent)
Full persistence-list rationale + UX flow + audit refs: expansion-model.md § Restart as Cosmetic Re-allocation.
History — the original 2026-05-11 incident
Pre-Phase-1, resetZoo wiped Golden Tickets entirely on restart. Trinity (AMAWNE6X) lost ~4.1k GT on 2026-05-11 after restarting; she had received 7 separate admin grants totaling 5.2k GT over the prior weeks, all of which were nuked. Audit identified 5 other affected players with significant GT gaps. added the GT-preserve guarantee; Phase 1 generalized the policy to refund-on-voluntary while preserving the BANKRUPTCY path's GT-balance-preservation to prevent a different exploit (intentional bankruptcy as free reset).
Monthly Season Reward Sinks (fb 870/880/882/888/904/933/936/950)
The Monthly Leaderboard Seasons system pays each archived season's top 100 from that season's own prize_pool_json: Golden Tickets and a cosmetic zone skin to the top three, a Golden Ticket to ranks 4 to 10, and Zoo Bucks to ranks 11 to 100, for every season the current rollover opens. A season opened before the tiers pays its top three only. The amounts and the rule that ranks the season (that calendar month's visitors, from the first season the visitor-rank procedure closes) are in management.md § Monthly Seasons, which points at the procedure that defines them; this section does not restate them.
Currency rationale. Golden Tickets couple players into the ISP funnel (subscribers care about scarcity; non-subscribers see a tangible path to premium content without paying), so they stay with the top ten. A Zoo Bucks reward to ranks 11 to 100 does not re-inflate the metric the season ranks against, because the season ranks the month's visitors and a reward lands on the balance. Cosmetic zone skins are pure prestige with zero balance impact and zero real-money loss on restart.
Grant path. zoo_simulator.sp_zoo_sim_season_rollover writes season_standings rows with rewards_granted=0 at month boundary. The Spring-scheduled sweeps every 5 minutes, routing each pending row through .grantCurrency(customerId, "zoo", currency, amount, reason) for each currency the pool names for its rank, so the grant rides the live cache write path and cannot be stomped by an active session (per the Trinity 2026-04-24 incident). Cosmetic unlocks idempotently land in zoo_simulator.zone_cosmetic_grants keyed by (customer_id, cosmetic_key). Full design + audit query in management.md § Monthly Seasons.
Stacking with weekly leaderboard reward. Weekly Zoo Buck payouts (5000/2500 ZB to top-3 weekly) are unaffected. Players can earn both: weekly ZB on showLeaderboard and monthly Golden Tickets at the rollover boundary. Different currencies, different cadences, different reset rules.
Aspirational Login Readout (queue / Rec 7)
overrides calculateDailyRewards, calculateMonthlyRewards, and calculateHolidayRewards to call super then mutate PayoutResult.readoutText via appendAspirationalContext. The appended line reads back the post-grant Golden Ticket balance and the next-purchase target framed against the cheapest premium animal (Albino Peacock, 40 GT):
- When post-grant balance is at or above the cheapest premium animal cost: "You now have N Golden Tickets, enough for an Albino Peacock. Say premium shop to browse."
- When still saving: "You now have N Golden Tickets. An Albino Peacock costs 40, keep playing to earn M more."
Replaces the prior terse "You receive 1000 zoo bucks and 1 golden ticket." Voice-friendly per design/engine/index.md (no abbreviations, natural language). Design source: isp-conversion-review.md § Rec 7.