Skip to main content

Economy

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_transactions row with transaction_type='GRANT' and product_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 in fromReferenceName).
  • 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 from STARTER_PACK dict + 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:

  1. 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.
  2. 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.md Rec 1 / Rec 7).
  3. 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 starterGoldenTicketsGranted boolean 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

  1. Activation: activatePromotion() validates dates, deducts cost, adds to activePromotions
  2. Expiry check: removeExpiredPromotions() removes promotions where endDate.isBefore(now)
  3. 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 rides loop the single-build path N times (, ). The zone N phrase is stripped before the count is read so build habitat savanna in zone 2 stays 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> N buys 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:

  1. Recognize the animal — an unrecognized name returns the available-animals list.
  2. 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.
  3. Habitat availability — reached only once the player can afford the animal. If no habitat has space, prompt to build one.
  4. 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 by lastRestartedAt)
  • skillCustomerId, accountRef, zooName (identity)
  • game_log.purchase_transactions rows (separate table — ISP audit trail untouched)
  • ISP entitlements (Amazon-side, ZOO_SIMULATOR_STARTER_PACK permanent)

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.