diff --git a/CLAUDE.md b/CLAUDE.md index 4476e0f..73bdee2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Business production (stock, supplies, value) | `data/production.ts`, `lib/production.ts` (012) | | Nightclub warehouse goods | `data/nightclub.ts`, `lib/nightclub.ts` (015) | | Business card order (drag, keys) | `lib/order.ts`, `ui/Panel` `move` (decision 014) | +| Safes and nightclub popularity | `data/safes.ts`, `lib/safes.ts` (decision 016) | | Theme tokens | `src/app.css` (decision 009) | | CI, templates | `.github/` | | Skills: `handoff`, `finish-pr`; agent: `scout` | `.claude/` | diff --git a/docs/decisions/016-safes.md b/docs/decisions/016-safes.md new file mode 100644 index 0000000..2369ea3 --- /dev/null +++ b/docs/decisions/016-safes.md @@ -0,0 +1,54 @@ +# 016: Property safes and nightclub popularity + +2026-10-14, issue #8. Builds on decisions 003, 004, 010, 011 and 013. + +## Decision + +- **Own tab.** Safes get a "Safes" tab instead of more on the Businesses cards: Nightclub, + Agency, Arcade, Salvage Yard and Garment Factory. All but the Nightclub join the catalog with + `card: false` (no Businesses card); their settings are on the Safes card's gear and in + Settings. +- **Data** (`src/data/safes.ts`): per safe, its capacity (a bigger one with an upgrade), how + it pays per in-game day and, for the nightclub, popularity loss and one-click boosts. +- **Payouts on play time,** once per in-game day (48 minutes), as a lump at the end of the day, + up to the capacity. Saved state (version 7): `safes` maps a property id to the money, the play + time since the last payout and, for the nightclub, popularity in percent. +- **Nightclub:** paid by the sourced popularity table (the row at or below the popularity), + then popularity drops by the day's loss. Loss 10% a day, 5% with Staff: an estimate (guides + disagree, the wiki only says Staff reduces it; issue #41). Boosts: Maxed (a new DJ fills the bar), + Promotion +25% (sourced), DJ +10% (switching back, sourced), Event +10% (Marcel's jobs, + estimate). Below the top payout the card warns "Payout cut" with the payout at the current + popularity. +- **Agency:** $250 base plus $500 per 5 security contracts, at most $20,000. Contracts done is + typed as a number. Step size and base are estimates (issue #40). +- **Count settings:** a third upgrade kind next to toggle and choice (decision 011): a whole + number typed in (`NumberField`), with a default and an optional maximum, saved as a number. + Used for contracts done and arcade floor slots; long dropdowns were hard to use. +- **Arcade:** $5,000 with all 35 floor slots filled (default 0 filled, like every other count + setting); fewer slots get an even + share ($5,000 × filled / 35), marked as an estimate (issue #42). A machine taking two slots counts as two. +- **Salvage Yard:** too little documented to track reputation (its decay has no source), so it + pays the 0% reputation amount ($250, $300 with Staff). $250,000 with the Wall Safe, $100,000 + without (issue #43). +- **Garment Factory:** pays a random $1,500 to $2,000 a day (gta.wiki), no upgrade changes it. + The card shows the range; LSOS counts the middle, $1,750, marked as an estimate. +- **"Full in"** assumes popularity stays where it is now, so it grows as popularity falls + instead of resting on the estimated loss. +- **Popularity bar** is split into five segments in the edit controls, like the business bars + (decision 010). +- **Estimates** follow decision 013: numbers that rest on them say "(est.)" and each card with + one carries a small best-estimate note. +- **Resync** uses `EditableValue` (decision 010): safe money (Collected, Full) and popularity + (also a slider, with the boosts as presets). +- **Payout time** is synced with a "Payout just arrived" button instead of typing minutes: the + game notifies each payout, and a day is at most 48 minutes, so a typed countdown was more + work than it was worth. Pressing it restarts the day. If LSOS's next payout was due within + half a day, it has not counted this one yet and pays it now; otherwise it counted it moments + ago and only the timer moves. + +## Why + +Safes, popularity and the four properties are their own topic and would crowd the business +cards. Lump payouts at the end of a day match how the game pays, so the next payout and the +time until full are exact for sourced values. Best estimates, clearly marked, are more useful +than leaving the less documented safes out. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 7b64313..d6d9174 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -21,8 +21,9 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [008](008-clock-details.md) Clock details: session play time, opens paused (setting), throttled ticks counted, long gaps asked about - [009](009-terminal-design.md) Terminal look: tokens in `app.css`, self-hosted Oswald (OFL), browser-style tabs, base components in `src/components/ui/` - [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since, five-segment bars you can drag -- [011](011-owned-properties.md) Owned properties and upgrades: catalog in `src/data/properties.ts`, toggle/choice upgrades, Settings panel and Businesses gear +- [011](011-owned-properties.md) Owned properties and upgrades: catalog in `src/data/properties.ts`, toggle/choice upgrades (count: 016), Settings panel and Businesses gear - [012](012-business-production.md) Business production: rate per exact upgrade combination in `src/data/production.ts`, continuous on play time, unknown rates do not run - [013](013-marked-estimates.md) Missing game values may be estimated by a stated rule from sourced ones, marked "(est.)" wherever shown - [014](014-card-order.md) Business cards: drag a title bar to lift a card and swap it with another, or move it with arrow keys; order saved as property ids - [015](015-nightclub-warehouse.md) Nightclub warehouse: goods per technician on play time, saved in crates, unlocked by owning the business, best technician setup by value per hour +- [016](016-safes.md) Safes tab: Nightclub, Agency, Arcade, Salvage Yard, Garment Factory paid per in-game day of play time, nightclub popularity with estimated loss and boosts diff --git a/src/App.svelte b/src/App.svelte index 4d07500..065df61 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,14 +2,17 @@ import Boot from './components/Boot.svelte' import Businesses from './components/Businesses.svelte' import ClockBar from './components/ClockBar.svelte' + import Safes from './components/Safes.svelte' import SaveStatus from './components/SaveStatus.svelte' import Settings from './components/Settings.svelte' import TabBar from './components/ui/TabBar.svelte' import { GOODS } from './data/nightclub' import { PRODUCTION } from './data/production' import { PROPERTIES } from './data/properties' + import { SAFES } from './data/safes' import { clocks } from './lib/clock.svelte' import './lib/production.svelte' + import './lib/safes.svelte' import { store } from './lib/store.svelte' import { TABS, tabFromHash } from './lib/tabs' @@ -61,6 +64,15 @@ onnightclub={(id, crates) => (store.state.nightclub = { ...store.state.nightclub, [id]: crates })} /> + {:else if active === 'safes'} + (store.state.properties = owned)} + stored={store.state.safes} + onresync={(id, safe) => (store.state.safes = { ...store.state.safes, [id]: safe })} + /> {:else if active === 'settings'} {:else} diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte index 246ca4d..fb9437c 100644 --- a/src/components/Businesses.svelte +++ b/src/components/Businesses.svelte @@ -21,6 +21,7 @@ import PropertySettings from './PropertySettings.svelte' import Button from './ui/Button.svelte' import Panel from './ui/Panel.svelte' + import SettingsButton from './ui/SettingsButton.svelte' interface Props { catalog: readonly Property[] @@ -193,6 +194,7 @@ const parts = property.upgrades.flatMap((upgrade) => { const value = upgradeValue(owned, property.id, upgrade) if (upgrade.kind === 'toggle') return value === true ? [upgrade.label] : [] + if (upgrade.kind === 'count') return [`${upgrade.label}: ${value}`] if (upgrade.summary === false || value === upgrade.options[0]?.id) return [] const option = upgrade.options.find((o) => o.id === value) return option ? [`${upgrade.label}: ${option.label}`] : [] @@ -260,18 +262,11 @@ >{editing === property.id ? 'Done' : 'Edit'} {/if} - + /> {/snippet} @@ -342,8 +337,4 @@ color: var(--muted); font-size: var(--text-sm); } - - svg { - display: block; - } diff --git a/src/components/PropertySettings.svelte b/src/components/PropertySettings.svelte index e670f6e..e4d2ac5 100644 --- a/src/components/PropertySettings.svelte +++ b/src/components/PropertySettings.svelte @@ -2,6 +2,7 @@ // Owned yes/no and the upgrades of one property (decision 011). import type { Property } from '../data/properties' import Button from './ui/Button.svelte' + import NumberField from './ui/NumberField.svelte' import { isOwned, setOwned, @@ -47,6 +48,16 @@ /> {upgrade.label} + {:else if upgrade.kind === 'count'} +
+ onchange(setUpgrade(owned, property.id, upgrade.id, value))} + /> +
{:else}
@@ -106,7 +117,8 @@ padding-left: var(--space-3); } - .upgrades > label { + .upgrades > label, + .count { grid-column: 1 / -1; } diff --git a/src/components/SafeCard.svelte b/src/components/SafeCard.svelte new file mode 100644 index 0000000..7321df5 --- /dev/null +++ b/src/components/SafeCard.svelte @@ -0,0 +1,187 @@ + + +{#if popularity !== undefined} + {#if editing} + ({ + label: boost.label, + value: Math.min(100, popularity + boost.adds), + title: boost.title, + })), + ]} + onresync={(value) => onresync({ ...current, popularity: value })} + > + (dragging = value)} + onset={(value) => onresync({ ...current, popularity: value })} + /> + + {:else} + + {/if} + {#if payout < best} +

+ Payout cut. At {popularity}% popularity the safe gets {money(payout)} a day; + {maxFrom}% or more pays {money(best)}. +

+ {/if} +{/if} + +{#if editing} + onresync({ ...current, money: value })} + /> +{:else} + +{/if} + +
+ {est('Payout', estimated.payout)} + {payoutText} +
+ + +{#if editing} +
+ +

Press when the game says money was added to this safe.

+
+{/if} +{#if full === null} +

Never full: this safe gets nothing a day.

+{:else} + +{/if} + +{#if safe.estimate} +

{safe.estimate}

+{/if} + + diff --git a/src/components/Safes.svelte b/src/components/Safes.svelte new file mode 100644 index 0000000..e3558a4 --- /dev/null +++ b/src/components/Safes.svelte @@ -0,0 +1,90 @@ + + +{#if shown.length === 0} +

+ You do not own a property with a safe yet: {safes + .map((safe) => catalog.find((p) => p.id === safe.propertyId)?.label) + .join(', ')}. Mark what you own in Settings. +

+{:else} +
+ {#each shown as { safe, property } (property.id)} + + {#if open === property.id} + + {/if} + onresync(property.id, state)} + /> + {#snippet footer()} + + (open = open === property.id ? null : property.id)} + /> + {/snippet} + + {/each} +
+{/if} + + diff --git a/src/components/ui/EditableValue.svelte b/src/components/ui/EditableValue.svelte index cb846dd..c83653d 100644 --- a/src/components/ui/EditableValue.svelte +++ b/src/components/ui/EditableValue.svelte @@ -14,6 +14,8 @@ interface Preset { label: string value: number + /** What the preset stands for in game, shown on hover. */ + title?: string } interface Props { @@ -146,7 +148,7 @@ {#if presets.length > 0 || last}
{#each presets as preset (preset.label)} - + {/each} {#if last} + + diff --git a/src/data/business-events.ts b/src/data/business-events.ts index ca7932d..36114ce 100644 --- a/src/data/business-events.ts +++ b/src/data/business-events.ts @@ -51,6 +51,6 @@ export const BAR_SEGMENTS = { source: 'gta.wiki, Business Supplies: both bars "are broken up into five segments" (MC businesses). ' + 'Bunker: segments seen in play (issue #32); gta.wiki, Supplies: most resupply missions fill ' + - '"1/5th, or 20 units".', + '"1/5th, or 20 units". Nightclub popularity: the same five segments, seen in play (issue #8).', checked: '2026-10-14', } diff --git a/src/data/properties.ts b/src/data/properties.ts index 2e8efa9..e7056d1 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -19,13 +19,18 @@ export type Upgrade = /** False to leave it out of the card's upgrade summary, when the card shows it anyway. */ summary?: false } + /** A whole number typed in, e.g. security contracts done. */ + | { kind: 'count'; id: string; label: string; max?: number; default: number } export interface Property { /** Stable id, saved in user data: never change or reuse it. */ id: string label: string upgrades: readonly Upgrade[] - /** False for properties that only unlock something elsewhere: no card on the Businesses tab. */ + /** + * False for properties with no card on the Businesses tab: those that only unlock something + * elsewhere, or that only have a safe (Safes tab). + */ card?: false source: string checked: string @@ -38,6 +43,7 @@ import { NIGHTCLUB_CHECKED, NIGHTCLUB_SOURCE, } from './nightclub' +import { ARCADE_SLOTS, SAFES, SAFES_CHECKED } from './safes' // Equipment and staff speed up production and raise the value; equipment also makes supplies // last longer. The security upgrade only changes raids, which LSOS does not predict: left out. @@ -83,9 +89,10 @@ export const PROPERTIES: readonly Property[] = [ { id: 'nightclub', label: 'Nightclub', - // Staff and security upgrades only change popularity and raids: left out. + // Security only changes raids: left out. Staff slows the popularity loss (Safes tab). upgrades: [ { kind: 'toggle', id: 'equipment', label: 'Equipment' }, + { kind: 'toggle', id: 'staff', label: 'Staff' }, { kind: 'choice', id: 'floors', @@ -111,6 +118,51 @@ export const PROPERTIES: readonly Property[] = [ source: NIGHTCLUB_SOURCE, checked: NIGHTCLUB_CHECKED, }, + // Safes only (issue #8, decision 016): no card on the Businesses tab. + { + id: 'arcade', + label: 'Arcade', + upgrades: [ + // A machine that takes two slots counts as two. + { + kind: 'count', + id: 'floor-slots', + label: `Floor slots filled (of ${ARCADE_SLOTS})`, + max: ARCADE_SLOTS, + default: 0, + }, + ], + card: false, + source: SAFES.find((safe) => safe.propertyId === 'arcade')!.source, + checked: SAFES_CHECKED, + }, + { + id: 'agency', + label: 'Agency', + upgrades: [{ kind: 'count', id: 'contracts', label: 'Security contracts done', default: 0 }], + card: false, + source: SAFES.find((safe) => safe.propertyId === 'agency')!.source, + checked: SAFES_CHECKED, + }, + { + id: 'garment-factory', + label: 'Garment Factory', + upgrades: [], + card: false, + source: SAFES.find((safe) => safe.propertyId === 'garment-factory')!.source, + checked: SAFES_CHECKED, + }, + { + id: 'salvage-yard', + label: 'Salvage Yard', + upgrades: [ + { kind: 'toggle', id: 'staff', label: 'Staff' }, + { kind: 'toggle', id: 'wall-safe', label: 'Wall Safe' }, + ], + card: false, + source: SAFES.find((safe) => safe.propertyId === 'salvage-yard')!.source, + checked: SAFES_CHECKED, + }, ] function mc(id: string, label: string): Property { diff --git a/src/data/safes.ts b/src/data/safes.ts new file mode 100644 index 0000000..e07b766 --- /dev/null +++ b/src/data/safes.ts @@ -0,0 +1,196 @@ +// Property safes: what they pay per in-game day, how much they hold, and nightclub popularity +// (decision 004, decision 013, decision 016). + +/** One in-game day, the payout interval of every safe: 48 minutes of play time. */ +export const PAYOUT_MINUTES = 48 + +/** A row of a payout table: what a safe pays per day from this percent up to the next row. */ +export interface PayoutRow { + from: number + amount: number +} + +export type SafePayout = + /** By popularity: the row with the highest `from` at or below it. */ + | { kind: 'popularity'; rows: readonly PayoutRow[] } + /** + * `base` plus `perStep` for each full step of a count upgrade, up to `max`; e.g. $500 per 5 + * security contracts. + */ + | { + kind: 'steps' + upgrade: string + base: number + perStep: number + stepSize: number + max: number + } + /** `full` with all `slots` filled (a count upgrade), else that share of it. */ + | { kind: 'slots'; upgrade: string; full: number; slots: number } + /** A fixed amount, or another with an upgrade bought. */ + | { kind: 'fixed'; amount: number; withUpgrade?: { upgrade: string; amount: number } } + /** A random amount from `min` to `max` each day; tracked as the middle of the two. */ + | { kind: 'range'; min: number; max: number } + +/** A one-click popularity change, e.g. a finished promotion job. */ +export interface Boost { + label: string + /** Percent added, up to 100. */ + adds: number + /** What it stands for in game, shown as a tooltip. */ + title: string + /** True if the amount is an estimate (decision 013). */ + estimate?: true +} + +export interface Safe { + /** The property whose safe this is; also the key of its saved state. */ + propertyId: string + /** Most money the safe holds; more with `bigger` bought. */ + capacity: number + bigger?: { upgrade: string; capacity: number } + payout: SafePayout + /** Nightclub only: popularity sets the payout and drops every in-game day. */ + popularity?: { + /** Percent lost per in-game day, and with the upgrade that slows it. */ + lossPerDay: number + slower: { upgrade: string; lossPerDay: number } + boosts: readonly Boost[] + } + /** + * Best-estimate note shown on the card. Set when the payout or popularity rests on estimates; + * which numbers get "(est.)" is in `estimatedParts` (lib/safes.ts). + */ + estimate?: string + source: string + checked: string +} + +export const SAFES_CHECKED = '2026-10-14' +/** Arcade floor slots; a machine that takes two counts as two. */ +export const ARCADE_SLOTS = 35 + +/** In card order: fastest to fill at the best payout first. */ +export const SAFES: readonly Safe[] = [ + { + propertyId: 'nightclub', + capacity: 250_000, + payout: { + kind: 'popularity', + rows: [ + { from: 95, amount: 50_000 }, + { from: 90, amount: 45_000 }, + { from: 85, amount: 25_000 }, + { from: 80, amount: 24_000 }, + { from: 75, amount: 23_000 }, + { from: 70, amount: 22_000 }, + { from: 65, amount: 21_000 }, + { from: 60, amount: 20_000 }, + { from: 55, amount: 10_000 }, + { from: 50, amount: 9_500 }, + { from: 45, amount: 9_000 }, + { from: 40, amount: 8_500 }, + { from: 35, amount: 8_000 }, + { from: 30, amount: 2_500 }, + { from: 25, amount: 2_200 }, + { from: 20, amount: 2_000 }, + { from: 15, amount: 1_800 }, + { from: 10, amount: 1_600 }, + { from: 0, amount: 1_500 }, + ], + }, + popularity: { + lossPerDay: 10, + slower: { upgrade: 'staff', lossPerDay: 5 }, + boosts: [ + { + label: 'Promotion +25%', + adds: 25, + title: 'A finished promotion job for Tony or Lazlow', + }, + { + label: 'DJ +10%', + adds: 10, + title: 'Switching back to a DJ you had before (a new DJ fills the bar: use Maxed)', + }, + { + label: 'Event +10% (est.)', + adds: 10, + title: 'Ejecting a troublemaker or driving a VIP home for Marcel. Amount estimated', + estimate: true, + }, + ], + }, + estimate: + 'Popularity loss per day (10%, 5% with Staff) and the event boost are best estimates: ' + + 'no source gives them.', + source: + 'gta.wiki, Nightclubs: safe holds $250,000, pays every in-game day (48 minutes) by ' + + 'popularity (table, current $50,000 maximum), Staff Upgrade "will reduce the daily ' + + 'popularity loss"; first switch to a DJ fills the bar, a switch back adds 10%. gta.wiki, ' + + 'Nightclubs/Nightclub Management: promotion jobs for Tony and Lazlow add 25%. Estimates ' + + '(issue #41): loss 10% per day, 5% with Staff (player guides disagree; gtabase, Nightclub ' + + 'money guide, says 5% with Staff); Marcel events +10% (the wiki gives no amount).', + checked: SAFES_CHECKED, + }, + { + propertyId: 'agency', + capacity: 250_000, + payout: { + kind: 'steps', + upgrade: 'contracts', + base: 250, + perStep: 500, + stepSize: 5, + max: 20_000, + }, + estimate: + 'The payout is a best estimate: $250 base plus $500 for every 5 security contracts, ' + + 'up to $20,000.', + source: + 'gta.wiki, Security Contracts: safe holds $250,000, payout capped at $20,000 per day ' + + '(201 contracts), rising with contracts done. Steps of $500 per 5 contracts: gtaboss, ' + + 'How to complete Security Contracts. Base $250: sportskeeda, GTA Online Agency passive ' + + 'income guide. Step and base payout unconfirmed (issue #40).', + checked: SAFES_CHECKED, + }, + { + propertyId: 'arcade', + capacity: 100_000, + payout: { kind: 'slots', upgrade: 'floor-slots', full: 5_000, slots: ARCADE_SLOTS }, + estimate: + 'With fewer than all 35 floor slots filled the payout is a best estimate: $5,000 shared ' + + 'evenly over the 35 slots.', + source: + 'gta.wiki, Arcades: safe holds $100,000, at most $5,000 per in-game day with every ' + + 'floor slot filled, any machine counts the same. 35 slots: faxagate, Arcade Games Guide; ' + + 'sportskeeda, What do GTA Online players get with the Arcade. Per-slot share: issue #42.', + checked: SAFES_CHECKED, + }, + { + propertyId: 'garment-factory', + capacity: 100_000, + payout: { kind: 'range', min: 1_500, max: 2_000 }, + estimate: + 'The game pays a random $1,500 to $2,000 a day; LSOS counts the middle, $1,750, so the ' + + 'safe money is a best estimate. Correct it when you visit.', + source: + 'gta.wiki, Garment Factory: employees generate a random $1,500 to $2,000 daily, collected ' + + 'from the safe in the office; the safe holds $100,000. No upgrade changes it.', + checked: SAFES_CHECKED, + }, + { + propertyId: 'salvage-yard', + capacity: 100_000, + bigger: { upgrade: 'wall-safe', capacity: 250_000 }, + payout: { kind: 'fixed', amount: 250, withUpgrade: { upgrade: 'staff', amount: 300 } }, + estimate: + 'Too little documented to track reliably. LSOS assumes 0% reputation (no towing): ' + + 'reputation drops each day at a rate no source gives.', + source: + 'gta.wiki, Salvage Yard: pays every in-game day (48 minutes) by reputation, $250 at 0% ' + + '($300 with Staff), the Wall Safe holds $250,000. Safe without the Wall Safe $100,000: ' + + 'gtaboss, How to get completely passive income. Open questions: issue #43.', + checked: SAFES_CHECKED, + }, +] diff --git a/src/lib/properties.test.ts b/src/lib/properties.test.ts index 5aebb14..7a788a5 100644 --- a/src/lib/properties.test.ts +++ b/src/lib/properties.test.ts @@ -58,6 +58,17 @@ describe('owning', () => { }) describe('upgradeValue', () => { + const count: Upgrade = { kind: 'count', id: 'slots', label: 'Slots', max: 35, default: 35 } + + it('reads a count, or its default when missing or out of range', () => { + expect(upgradeValue({}, 'a', count)).toBe(35) + expect(upgradeValue(setUpgrade({}, 'a', 'slots', 12), 'a', count)).toBe(12) + expect(upgradeValue(setUpgrade({}, 'a', 'slots', 0), 'a', count)).toBe(0) + expect(upgradeValue(setUpgrade({}, 'a', 'slots', 36), 'a', count)).toBe(35) + expect(upgradeValue(setUpgrade({}, 'a', 'slots', 2.5), 'a', count)).toBe(35) + expect(upgradeValue(setUpgrade({}, 'a', 'slots', '12'), 'a', count)).toBe(35) + }) + it('defaults to not bought and the first option', () => { expect(upgradeValue({}, 'a', toggle)).toBe(false) expect(upgradeValue({}, 'a', choice)).toBe('none') @@ -80,7 +91,7 @@ describe('upgradeValue', () => { describe('parseOwnedProperties', () => { it('keeps every entry, listed or not', () => { const data = { - a: { owned: true, upgrades: { tech: 'cash' } }, + a: { owned: true, upgrades: { tech: 'cash', contracts: 42 } }, old: { owned: false, upgrades: {} }, } expect(parseOwnedProperties(data)).toEqual(data) @@ -97,7 +108,7 @@ describe('parseOwnedProperties', () => { ['an array', [], 'properties section is missing'], ['a bad owned flag', { a: { owned: 'yes', upgrades: {} } }, 'property "a"'], ['missing upgrades', { a: { owned: true } }, 'property "a"'], - ['a bad upgrade', { a: { owned: true, upgrades: { tech: 3 } } }, 'upgrade "tech"'], + ['a bad upgrade', { a: { owned: true, upgrades: { tech: null } } }, 'upgrade "tech"'], ])('rejects %s', (_, data, message) => { expect(parseOwnedProperties(data)).toContain(message) }) diff --git a/src/lib/properties.ts b/src/lib/properties.ts index 8640316..bfc4eaa 100644 --- a/src/lib/properties.ts +++ b/src/lib/properties.ts @@ -1,8 +1,11 @@ // Which properties the user owns and which upgrades they bought (decision 011). import type { Property, Upgrade } from '../data/properties' -/** A toggle upgrade is saved as true/false, a choice upgrade as the chosen option's id. */ -export type UpgradeValue = boolean | string +/** + * A toggle upgrade is saved as true/false, a choice upgrade as the chosen option's id, a count + * as a whole number. + */ +export type UpgradeValue = boolean | string | number export interface PropertySettings { owned: boolean @@ -35,6 +38,14 @@ export function upgradeValue( ): UpgradeValue { const saved = owned[propertyId]?.upgrades[upgrade.id] if (upgrade.kind === 'toggle') return saved === true + if (upgrade.kind === 'count') { + const fits = + typeof saved === 'number' && + Number.isInteger(saved) && + saved >= 0 && + (upgrade.max === undefined || saved <= upgrade.max) + return fits ? saved : upgrade.default + } return upgrade.options.some((option) => option.id === saved) ? (saved as string) : (upgrade.options[0]?.id ?? '') @@ -76,10 +87,11 @@ export function parseOwnedProperties(data: unknown): OwnedProperties | string { } const upgrades: Record = {} for (const [upgradeId, value] of Object.entries(entry.upgrades)) { - if (typeof value !== 'boolean' && typeof value !== 'string') { + const isNumber = typeof value === 'number' && Number.isFinite(value) + if (typeof value !== 'boolean' && typeof value !== 'string' && !isNumber) { return `The saved upgrade "${upgradeId}" of property "${id}" is not readable.` } - upgrades[upgradeId] = value + upgrades[upgradeId] = value as UpgradeValue } result[id] = { owned: entry.owned, upgrades } } diff --git a/src/lib/safes.svelte.ts b/src/lib/safes.svelte.ts new file mode 100644 index 0000000..3b2bd69 --- /dev/null +++ b/src/lib/safes.svelte.ts @@ -0,0 +1,11 @@ +// Runs property safes on play time (issue #8). Logic lives in safes.ts; this file only wires it +// to the play clock and the store. +import { PROPERTIES } from '../data/properties' +import { SAFES } from '../data/safes' +import { onPlayTime } from './clock.svelte' +import { advanceSafes } from './safes' +import { store } from './store.svelte' + +onPlayTime((ms) => { + store.state.safes = advanceSafes(store.state.safes, SAFES, PROPERTIES, store.state.properties, ms) +}) diff --git a/src/lib/safes.test.ts b/src/lib/safes.test.ts new file mode 100644 index 0000000..37e7cfa --- /dev/null +++ b/src/lib/safes.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest' +import { PROPERTIES } from '../data/properties' +import { SAFES } from '../data/safes' +import type { OwnedProperties, UpgradeValue } from './properties' +import { + advanceSafe, + advanceSafes, + bestPayout, + capacity, + dailyPayout, + DAY_MS, + emptySafe, + estimatedParts, + fullInMs, + nextPayoutInMs, + parseSafes, + popularityLoss, + syncPayout, +} from './safes' + +const MIN = 60_000 +const safe = (id: string) => SAFES.find((s) => s.propertyId === id)! +const property = (id: string) => PROPERTIES.find((p) => p.id === id)! + +function owning(id: string, upgrades: Record = {}): OwnedProperties { + return { [id]: { owned: true, upgrades } } +} + +describe('data', () => { + it('has a property for every safe and a whole-dollar payout table from 0%', () => { + for (const s of SAFES) expect(property(s.propertyId)).toBeDefined() + const payout = safe('nightclub').payout + expect(payout.kind === 'popularity' && payout.rows.at(-1)?.from).toBe(0) + }) + + it('lasts one in-game day of 48 minutes between payouts', () => { + expect(DAY_MS).toBe(48 * MIN) + }) +}) + +describe('dailyPayout', () => { + const nightclub = [safe('nightclub'), owning('nightclub'), property('nightclub')] as const + + it('pays the nightclub by the popularity row at or below it', () => { + expect(dailyPayout(...nightclub, 100)).toBe(50_000) + expect(dailyPayout(...nightclub, 95)).toBe(50_000) + expect(dailyPayout(...nightclub, 94)).toBe(45_000) + expect(dailyPayout(...nightclub, 85)).toBe(25_000) + expect(dailyPayout(...nightclub, 3)).toBe(1_500) + }) + + it('pays the agency $250 plus $500 per 5 contracts, up to $20,000', () => { + const agency = (contracts?: number) => + dailyPayout( + safe('agency'), + owning('agency', contracts === undefined ? {} : { contracts }), + property('agency'), + ) + expect(agency()).toBe(250) + expect(agency(4)).toBe(250) + expect(agency(5)).toBe(750) + expect(agency(100)).toBe(10_250) + expect(agency(195)).toBe(19_750) + expect(agency(201)).toBe(20_000) + expect(agency(500)).toBe(20_000) + }) + + it('pays the arcade $5,000 when full, else its share of 35 slots', () => { + const arcade = (slots?: number) => + dailyPayout( + safe('arcade'), + owning('arcade', slots === undefined ? {} : { 'floor-slots': slots }), + property('arcade'), + ) + expect(arcade()).toBe(0) + expect(arcade(35)).toBe(5_000) + expect(arcade(14)).toBe(2_000) + expect(arcade(0)).toBe(0) + // Out of range reads as the default: none filled. + expect(arcade(40)).toBe(0) + }) + + it('pays the garment factory the middle of its random $1,500 to $2,000', () => { + expect( + dailyPayout(safe('garment-factory'), owning('garment-factory'), property('garment-factory')), + ).toBe(1_750) + }) + + it('pays the salvage yard its 0% reputation amount, more with Staff', () => { + const yard = (staff: boolean) => + dailyPayout(safe('salvage-yard'), owning('salvage-yard', { staff }), property('salvage-yard')) + expect(yard(false)).toBe(250) + expect(yard(true)).toBe(300) + }) + + it('knows the best payout of the nightclub', () => { + expect(bestPayout(safe('nightclub'), owning('nightclub'), property('nightclub'))).toBe(50_000) + }) +}) + +describe('upgrades', () => { + it('holds more in the salvage yard with the Wall Safe', () => { + const yard = (wall: boolean) => + capacity( + safe('salvage-yard'), + owning('salvage-yard', { 'wall-safe': wall }), + property('salvage-yard'), + ) + expect(yard(false)).toBe(100_000) + expect(yard(true)).toBe(250_000) + }) + + it('loses popularity slower with nightclub Staff', () => { + const loss = (staff: boolean) => + popularityLoss(safe('nightclub'), owning('nightclub', { staff }), property('nightclub')) + expect(loss(false)).toBe(10) + expect(loss(true)).toBe(5) + expect(popularityLoss(safe('arcade'), owning('arcade'), property('arcade'))).toBe(0) + }) + + it('marks estimated payouts and popularity', () => { + const parts = (id: string, upgrades: Record = {}) => + estimatedParts(safe(id), owning(id, upgrades), property(id)) + expect(parts('nightclub')).toEqual({ payout: false, popularity: true }) + expect(parts('agency')).toEqual({ payout: true, popularity: false }) + expect(parts('arcade')).toEqual({ payout: true, popularity: false }) + expect(parts('arcade', { 'floor-slots': 20 })).toEqual({ payout: true, popularity: false }) + expect(parts('arcade', { 'floor-slots': 35 })).toEqual({ payout: false, popularity: false }) + expect(parts('garment-factory').payout).toBe(true) + expect(parts('salvage-yard').payout).toBe(true) + }) +}) + +describe('advanceSafe', () => { + const nightclub = [safe('nightclub'), owning('nightclub'), property('nightclub')] as const + + it('pays at the end of each day, then loses popularity', () => { + const start = { money: 0, sinceMs: 40 * MIN, popularity: 100 } + expect(advanceSafe(start, ...nightclub, 7 * MIN)).toEqual({ + money: 0, + sinceMs: 47 * MIN, + popularity: 100, + }) + expect(advanceSafe(start, ...nightclub, 8 * MIN)).toEqual({ + money: 50_000, + sinceMs: 0, + popularity: 90, + }) + // 100% pays 50k, then 90% 45k, then 80% 24k. + expect(advanceSafe(start, ...nightclub, 8 * MIN + 2 * DAY_MS)).toEqual({ + money: 119_000, + sinceMs: 0, + popularity: 70, + }) + }) + + it('loses 5% a day with Staff', () => { + const staffed = [ + safe('nightclub'), + owning('nightclub', { staff: true }), + property('nightclub'), + ] as const + const after = advanceSafe({ money: 0, sinceMs: 0, popularity: 100 }, ...staffed, 2 * DAY_MS) + // 100% and 95% both pay 50k. + expect(after).toEqual({ money: 100_000, sinceMs: 0, popularity: 90 }) + }) + + it('stops at the capacity and keeps money set above it', () => { + const start = { money: 240_000, sinceMs: 0, popularity: 100 } + expect(advanceSafe(start, ...nightclub, DAY_MS).money).toBe(250_000) + const over = { money: 300_000, sinceMs: 0, popularity: 100 } + expect(advanceSafe(over, ...nightclub, DAY_MS).money).toBe(300_000) + }) + + it('does not go below 0% popularity', () => { + const after = advanceSafe({ money: 0, sinceMs: 0, popularity: 5 }, ...nightclub, DAY_MS) + expect(after.popularity).toBe(0) + }) + + it('has no popularity for other safes', () => { + const arcade = [ + safe('arcade'), + owning('arcade', { 'floor-slots': 35 }), + property('arcade'), + ] as const + expect(advanceSafe({ money: 0, sinceMs: 0 }, ...arcade, 3 * DAY_MS)).toEqual({ + money: 15_000, + sinceMs: 0, + }) + }) + + it('changes nothing without play time', () => { + const state = emptySafe(safe('nightclub')) + expect(advanceSafe(state, ...nightclub, 0)).toBe(state) + }) +}) + +describe('syncPayout', () => { + const nightclub = [safe('nightclub'), owning('nightclub'), property('nightclub')] as const + + it('pays a payout LSOS has not counted yet and restarts the day', () => { + const state = { money: 10_000, sinceMs: 40 * MIN, popularity: 100 } + expect(syncPayout(state, ...nightclub)).toEqual({ + money: 60_000, + sinceMs: 0, + popularity: 90, + }) + }) + + it('only restarts the day when LSOS counted the payout a moment ago', () => { + const state = { money: 60_000, sinceMs: 5 * MIN, popularity: 90 } + expect(syncPayout(state, ...nightclub)).toEqual({ ...state, sinceMs: 0 }) + }) +}) + +describe('forecasts', () => { + const nightclub = [safe('nightclub'), owning('nightclub'), property('nightclub')] as const + + it('counts down to the next payout', () => { + expect(nextPayoutInMs({ money: 0, sinceMs: 30 * MIN })).toBe(18 * MIN) + }) + + it('counts the days until full as if popularity stayed where it is', () => { + // 50k a day at 100%: 130k needs 3 payouts. + const state = { money: 120_000, sinceMs: 0, popularity: 100 } + expect(fullInMs(state, ...nightclub)).toBe(3 * DAY_MS) + // 25k a day at 85%: 6 payouts. + expect(fullInMs({ ...state, popularity: 85 }, ...nightclub)).toBe(6 * DAY_MS) + expect(fullInMs({ ...state, money: 200_000, sinceMs: 18 * MIN }, ...nightclub)).toBe(30 * MIN) + }) + + it('is 0 when full and null when nothing is paid', () => { + expect(fullInMs({ money: 250_000, sinceMs: 0, popularity: 0 }, ...nightclub)).toBe(0) + const arcade = [ + safe('arcade'), + owning('arcade', { 'floor-slots': 0 }), + property('arcade'), + ] as const + expect(fullInMs({ money: 0, sinceMs: 0 }, ...arcade)).toBeNull() + }) +}) + +describe('advanceSafes', () => { + it('pays owned safes only, starting empty', () => { + const owned = { + ...owning('arcade', { 'floor-slots': 35 }), + agency: { owned: false, upgrades: {} }, + } + expect(advanceSafes({}, SAFES, PROPERTIES, owned, DAY_MS)).toEqual({ + arcade: { money: 5_000, sinceMs: 0 }, + }) + }) + + it('returns the same object when nothing is owned', () => { + const safes = {} + expect(advanceSafes(safes, SAFES, PROPERTIES, {}, DAY_MS)).toBe(safes) + }) +}) + +describe('parseSafes', () => { + it('keeps valid entries', () => { + const data = { + nightclub: { money: 10, sinceMs: 5, popularity: 50 }, + arcade: { money: 0, sinceMs: 0 }, + } + expect(parseSafes(data)).toEqual(data) + }) + + it('rejects bad shapes', () => { + expect(parseSafes(null)).toBeTypeOf('string') + expect(parseSafes({ arcade: { money: -1, sinceMs: 0 } })).toBeTypeOf('string') + expect(parseSafes({ arcade: { money: 1 } })).toBeTypeOf('string') + expect(parseSafes({ nightclub: { money: 1, sinceMs: 0, popularity: 120 } })).toBeTypeOf( + 'string', + ) + }) +}) diff --git a/src/lib/safes.ts b/src/lib/safes.ts new file mode 100644 index 0000000..458f637 --- /dev/null +++ b/src/lib/safes.ts @@ -0,0 +1,213 @@ +// Property safes: money paid in once per in-game day of play time, nightclub popularity falling +// with it (issue #8, decision 016). Pure logic: play time comes in as an argument (decision 003). +import type { Property } from '../data/properties' +import { PAYOUT_MINUTES, type Safe } from '../data/safes' +import { isOwned, upgradeValue, type OwnedProperties, type UpgradeValue } from './properties' + +/** Play time of one in-game day, the payout interval. */ +export const DAY_MS = PAYOUT_MINUTES * 60_000 + +export interface SafeState { + /** Dollars in the safe. */ + money: number + /** Play time since the last payout. */ + sinceMs: number + /** Popularity in percent, for a safe whose payout depends on it. */ + popularity?: number +} + +/** Saved safes by property id. */ +export type SafesById = Record + +/** A safe nobody has entered yet: empty, a whole day to the first payout, popularity 0. */ +export function emptySafe(safe: Safe): SafeState { + return safe.popularity ? { money: 0, sinceMs: 0, popularity: 0 } : { money: 0, sinceMs: 0 } +} + +/** The upgrade values a safe reads, with their defaults (lib/properties.ts). */ +function upgrade(owned: OwnedProperties, property: Property, id: string): UpgradeValue { + const found = property.upgrades.find((u) => u.id === id) + return found ? upgradeValue(owned, property.id, found) : false +} + +export function capacity(safe: Safe, owned: OwnedProperties, property: Property): number { + return safe.bigger && upgrade(owned, property, safe.bigger.upgrade) === true + ? safe.bigger.capacity + : safe.capacity +} + +/** Dollars paid in at the end of an in-game day, at this popularity. */ +export function dailyPayout( + safe: Safe, + owned: OwnedProperties, + property: Property, + popularity = 0, +): number { + const payout = safe.payout + switch (payout.kind) { + case 'popularity': + return payout.rows.find((row) => popularity >= row.from)?.amount ?? 0 + case 'steps': { + const steps = Math.floor(Number(upgrade(owned, property, payout.upgrade)) / payout.stepSize) + return Math.min(payout.max, payout.base + steps * payout.perStep) + } + case 'slots': { + const filled = Math.min(payout.slots, Number(upgrade(owned, property, payout.upgrade))) + return Math.round((payout.full * filled) / payout.slots) + } + case 'fixed': + return payout.withUpgrade && upgrade(owned, property, payout.withUpgrade.upgrade) === true + ? payout.withUpgrade.amount + : payout.amount + case 'range': + return Math.round((payout.min + payout.max) / 2) + } +} + +/** The most this safe pays per day with its upgrades, popularity aside. */ +export function bestPayout(safe: Safe, owned: OwnedProperties, property: Property): number { + return dailyPayout(safe, owned, property, 100) +} + +/** Popularity lost per in-game day, or 0 for a safe without popularity. */ +export function popularityLoss(safe: Safe, owned: OwnedProperties, property: Property): number { + const popularity = safe.popularity + if (!popularity) return 0 + return upgrade(owned, property, popularity.slower.upgrade) === true + ? popularity.slower.lossPerDay + : popularity.lossPerDay +} + +/** + * Which shown numbers rest on estimates (decision 013): the payout (and so everything it adds + * up to), and popularity, which falls at an estimated rate. + */ +export function estimatedParts( + safe: Safe, + owned: OwnedProperties, + property: Property, +): { payout: boolean; popularity: boolean } { + const payout = safe.payout + return { + payout: + payout.kind === 'steps' || + payout.kind === 'fixed' || + payout.kind === 'range' || + (payout.kind === 'slots' && Number(upgrade(owned, property, payout.upgrade)) < payout.slots), + popularity: safe.popularity !== undefined, + } +} + +/** + * The safe after `playMs` of play time: at the end of each in-game day it is paid at the + * popularity of that moment, up to its capacity, and then popularity drops. Money already over + * the capacity (set by hand) stays until changed. + */ +export function advanceSafe( + state: SafeState, + safe: Safe, + owned: OwnedProperties, + property: Property, + playMs: number, +): SafeState { + if (playMs <= 0) return state + const cap = capacity(safe, owned, property) + const loss = popularityLoss(safe, owned, property) + let { money, popularity } = state + let sinceMs = state.sinceMs + playMs + while (sinceMs >= DAY_MS) { + sinceMs -= DAY_MS + if (money < cap) money = Math.min(cap, money + dailyPayout(safe, owned, property, popularity)) + if (popularity !== undefined) popularity = Math.max(0, popularity - loss) + } + return popularity === undefined ? { money, sinceMs } : { money, sinceMs, popularity } +} + +/** + * The safe right after the game says a payout came in: the day restarts now. If LSOS has not + * counted that payout yet (its next one was due within half a day), it is paid now, as at the + * end of a day; if LSOS counted it less than half a day ago, only the timer moves. + */ +export function syncPayout( + state: SafeState, + safe: Safe, + owned: OwnedProperties, + property: Property, +): SafeState { + if (state.sinceMs < DAY_MS / 2) return { ...state, sinceMs: 0 } + return advanceSafe(state, safe, owned, property, nextPayoutInMs(state)) +} + +/** Play time until the next payout. */ +export function nextPayoutInMs(state: SafeState): number { + return Math.max(0, DAY_MS - state.sinceMs) +} + +/** + * Play time until the safe is full if popularity stays where it is now, so the time grows as + * popularity falls. 0 when full; null if nothing is paid. + */ +export function fullInMs( + state: SafeState, + safe: Safe, + owned: OwnedProperties, + property: Property, +): number | null { + const cap = capacity(safe, owned, property) + if (state.money >= cap) return 0 + const paid = dailyPayout(safe, owned, property, state.popularity) + if (paid <= 0) return null + const payouts = Math.ceil((cap - state.money) / paid) + return nextPayoutInMs(state) + (payouts - 1) * DAY_MS +} + +/** + * Counts a stretch of play time for every owned property with a safe. Returns `safes` itself if + * nothing changed, so an idle tick does not trigger a save. + */ +export function advanceSafes( + safes: SafesById, + data: readonly Safe[], + catalog: readonly Property[], + owned: OwnedProperties, + playMs: number, +): SafesById { + let next = safes + for (const safe of data) { + const property = catalog.find((p) => p.id === safe.propertyId) + if (!property || !isOwned(owned, property.id)) continue + const state = safes[safe.propertyId] ?? emptySafe(safe) + const after = advanceSafe(state, safe, owned, property, playMs) + if (after !== state) next = { ...next, [safe.propertyId]: after } + } + return next +} + +/** + * Checks saved safes. Entries are kept for any id, like owned properties (decision 011). + * Returns an error message if the shape is wrong. + */ +export function parseSafes(data: unknown): SafesById | string { + if (!isRecord(data)) return 'The safes section is missing.' + const result: SafesById = {} + for (const [id, entry] of Object.entries(data)) { + const bad = `The saved safe of "${id}" is not readable.` + if (!isRecord(entry) || !isAmount(entry.money) || !isAmount(entry.sinceMs)) return bad + const state: SafeState = { money: entry.money, sinceMs: entry.sinceMs } + if (entry.popularity !== undefined) { + const { popularity } = entry + if (typeof popularity !== 'number' || !(popularity >= 0 && popularity <= 100)) return bad + state.popularity = popularity + } + result[id] = state + } + return result +} + +function isAmount(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index 69fe5df..5b5951f 100644 --- a/src/lib/state.test.ts +++ b/src/lib/state.test.ts @@ -25,12 +25,13 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 6, + version: 7, clock: { trackOnOpen: false }, properties: {}, businesses: {}, businessOrder: [], nightclub: {}, + safes: {}, }, fromVersion: 1, }) @@ -41,12 +42,13 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 6, + version: 7, clock: { trackOnOpen: true }, properties: {}, businesses: {}, businessOrder: [], nightclub: {}, + safes: {}, }, fromVersion: 2, }) @@ -60,12 +62,13 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 6, + version: 7, clock: { trackOnOpen: false }, properties, businesses: {}, businessOrder: [], nightclub: {}, + safes: {}, }, fromVersion: 3, }) @@ -80,12 +83,13 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 6, + version: 7, clock: { trackOnOpen: true }, properties, businesses, businessOrder: [], nightclub: {}, + safes: {}, }, fromVersion: 4, }) @@ -102,11 +106,33 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 6, nightclub: {} }, + state: { ...sample, version: 7, nightclub: {}, safes: {} }, fromVersion: 5, }) }) + it('migrates a version 6 sample, with empty safes', () => { + const sample = { + version: 6, + clock: { trackOnOpen: true }, + properties: { nightclub: { owned: true, upgrades: { equipment: true } } }, + businesses: {}, + businessOrder: [], + nightclub: { cash: 4 }, + } + expect(parseState(JSON.stringify(sample))).toEqual({ + ok: true, + state: { ...sample, version: 7, safes: {} }, + fromVersion: 6, + }) + }) + + it('keeps saved safes', () => { + const safes = { nightclub: { money: 50_000, sinceMs: 600_000, popularity: 85 } } + const text = JSON.stringify({ ...defaultState(), safes }) + expect(parseState(text)).toMatchObject({ ok: true, state: { safes } }) + }) + it('keeps saved nightclub stock', () => { const text = JSON.stringify({ ...defaultState(), nightclub: { cash: 12.5 } }) expect(parseState(text)).toMatchObject({ ok: true, state: { nightclub: { cash: 12.5 } } }) @@ -114,7 +140,7 @@ describe('parseState', () => { it('keeps the saved clock setting', () => { const text = - '{"version":6,"clock":{"trackOnOpen":true,"playMs":5},"properties":{},"businesses":{},"businessOrder":[],"nightclub":{}}' + '{"version":7,"clock":{"trackOnOpen":true,"playMs":5},"properties":{},"businesses":{},"businessOrder":[],"nightclub":{},"safes":{}}' expect(parseState(text)).toMatchObject({ ok: true, state: { clock: { trackOnOpen: true } } }) }) diff --git a/src/lib/state.ts b/src/lib/state.ts index 6d9c03f..304938f 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -4,9 +4,10 @@ import { parseGoodsStock, type GoodsStock } from './nightclub' import { parseOrder } from './order' import { parseBusinessBars, type BusinessBarsById } from './production' import { parseOwnedProperties, type OwnedProperties } from './properties' +import { parseSafes, type SafesById } from './safes' /** Bump when the stored shape changes, and add a migration from the previous version. */ -export const CURRENT_VERSION = 6 +export const CURRENT_VERSION = 7 /** All user state. Trackers add their own sections here as they are built. */ export interface AppState { @@ -21,6 +22,8 @@ export interface AppState { businessOrder: string[] /** Nightclub warehouse crates by goods id, as of the last play time counted (issue #7). */ nightclub: GoodsStock + /** Money in each safe and nightclub popularity, as of the last play time counted (issue #8). */ + safes: SafesById } export function defaultState(): AppState { @@ -31,6 +34,7 @@ export function defaultState(): AppState { businesses: {}, businessOrder: [], nightclub: {}, + safes: {}, } } @@ -51,6 +55,8 @@ export const MIGRATIONS: Readonly> = { 4: (data) => ({ ...data, businessOrder: [] }), // Version 6 adds the nightclub warehouse stock (issue #7); empty until set or produced. 5: (data) => ({ ...data, nightclub: {} }), + // Version 7 adds property safes (issue #8); empty until set or paid. + 6: (data) => ({ ...data, safes: {} }), } export type ParseResult = @@ -129,7 +135,17 @@ function toState(data: StoredData): AppState | string { if (typeof businessOrder === 'string') return businessOrder const nightclub = parseGoodsStock(data.nightclub) if (typeof nightclub === 'string') return nightclub - return { version: CURRENT_VERSION, clock, properties, businesses, businessOrder, nightclub } + const safes = parseSafes(data.safes) + if (typeof safes === 'string') return safes + return { + version: CURRENT_VERSION, + clock, + properties, + businesses, + businessOrder, + nightclub, + safes, + } } function toClock(data: unknown): ClockSettings | string { diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts index 51fc15f..f34bcc1 100644 --- a/src/lib/tabs.ts +++ b/src/lib/tabs.ts @@ -1,6 +1,7 @@ export const TABS = [ { id: 'overview', label: 'Overview' }, { id: 'businesses', label: 'Businesses' }, + { id: 'safes', label: 'Safes' }, { id: 'heists', label: 'Heists' }, { id: 'weekly', label: 'Weekly' }, { id: 'settings', label: 'Settings' },