From fc7334391295120ae6ddea443d4a5929ba7b553f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:51:40 +0000 Subject: [PATCH 1/4] Safes tab: Nightclub, Agency, Arcade and Salvage Yard safes with nightclub popularity Each owned property with a safe gets a card on a new Safes tab: money and capacity, payout per in-game day, next payout and time until full on play time. Nightclub popularity drives its payout, falls each day and has Maxed, Promotion, DJ and Event buttons. Estimated values are marked (decision 013); open data questions are issues #40 to #43. Saved state version 7. Closes #8 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/016-safes.md | 38 ++++ docs/decisions/README.md | 1 + src/App.svelte | 12 ++ src/components/Businesses.svelte | 20 +-- src/components/SafeCard.svelte | 177 ++++++++++++++++++ src/components/Safes.svelte | 90 ++++++++++ src/components/ui/EditableValue.svelte | 4 +- src/components/ui/SettingsButton.svelte | 28 +++ src/data/properties.ts | 62 ++++++- src/data/safes.ts | 166 +++++++++++++++++ src/lib/safes.svelte.ts | 11 ++ src/lib/safes.test.ts | 230 ++++++++++++++++++++++++ src/lib/safes.ts | 205 +++++++++++++++++++++ src/lib/state.test.ts | 38 +++- src/lib/state.ts | 20 ++- src/lib/tabs.ts | 1 + 17 files changed, 1078 insertions(+), 26 deletions(-) create mode 100644 docs/decisions/016-safes.md create mode 100644 src/components/SafeCard.svelte create mode 100644 src/components/Safes.svelte create mode 100644 src/components/ui/SettingsButton.svelte create mode 100644 src/data/safes.ts create mode 100644 src/lib/safes.svelte.ts create mode 100644 src/lib/safes.test.ts create mode 100644 src/lib/safes.ts 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..260b3df --- /dev/null +++ b/docs/decisions/016-safes.md @@ -0,0 +1,38 @@ +# 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 and Salvage Yard. Agency, Arcade and Salvage Yard 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". +- **Agency:** $500 per 5 security contracts (setting in steps of 5), at most $20,000; no base + payout. Step size and base are estimates (issue #40). +- **Arcade:** $5,000 with all 35 floor slots filled; 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). +- **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), popularity (also + a slider, with the boosts as presets) and the time to the next payout. + +## 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..fd25b80 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -26,3 +26,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [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 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..06ebac8 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[] @@ -260,18 +261,11 @@ >{editing === property.id ? 'Done' : 'Edit'} {/if} - + /> {/snippet} @@ -342,8 +336,4 @@ color: var(--muted); font-size: var(--text-sm); } - - svg { - display: block; - } diff --git a/src/components/SafeCard.svelte b/src/components/SafeCard.svelte new file mode 100644 index 0000000..0c71d92 --- /dev/null +++ b/src/components/SafeCard.svelte @@ -0,0 +1,177 @@ + + +{#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. Under {maxFrom}% popularity the safe gets {money(payout)} a day instead + of {money(best)}. +

+ {/if} +{/if} + +{#if editing} + onresync({ ...current, money: value })} + /> + onresync({ ...current, sinceMs: DAY_MS - value })} + /> +{:else} + +{/if} + +
+ {est('Payout', estimated.payout)} + {money(payout)} a day +
+ +{#if !editing} + +{/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/properties.ts b/src/data/properties.ts index 2e8efa9..13f9c8d 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -25,7 +25,10 @@ export interface Property { 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 +41,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 +87,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 +116,59 @@ 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: [ + { + kind: 'choice', + id: 'floor-slots', + label: 'Floor slots filled', + // A machine that takes two slots counts as two. + options: [ + { id: 'full', label: `All ${ARCADE_SLOTS}` }, + ...Array.from({ length: ARCADE_SLOTS }, (_, i) => { + const n = String(ARCADE_SLOTS - 1 - i) + return { id: n, label: n } + }), + ], + }, + ], + card: false, + source: SAFES.find((safe) => safe.propertyId === 'arcade')!.source, + checked: SAFES_CHECKED, + }, + { + id: 'agency', + label: 'Agency', + upgrades: [ + { + kind: 'choice', + id: 'contracts', + label: 'Security contracts done', + // The payout rises every 5 contracts and stops at 200. + options: Array.from({ length: 41 }, (_, i) => ({ + id: String(i * 5), + label: i === 40 ? '200 or more' : `${i * 5} to ${i * 5 + 4}`, + })), + }, + ], + card: false, + source: SAFES.find((safe) => safe.propertyId === 'agency')!.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..dafd900 --- /dev/null +++ b/src/data/safes.ts @@ -0,0 +1,166 @@ +// 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[] } + /** + * `perStep` for each full step of a choice upgrade whose option ids are counts, up to `max`; + * e.g. $500 per 5 security contracts. + */ + | { kind: 'steps'; upgrade: string; perStep: number; stepSize: number; max: number } + /** `full` with every floor slot filled (option "full"), else that share of the `slots`. */ + | { 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 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 + +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', perStep: 500, stepSize: 5, max: 20_000 }, + estimate: + 'The payout per contract is a best estimate: $500 for every 5 security contracts, ' + + 'no base payout.', + 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. 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 empty floor slots 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: '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/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..9f7ea55 --- /dev/null +++ b/src/lib/safes.test.ts @@ -0,0 +1,230 @@ +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, +} 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 $500 per 5 contracts, up to $20,000', () => { + const agency = (contracts: string) => + dailyPayout(safe('agency'), owning('agency', { contracts }), property('agency')) + expect(agency('0')).toBe(0) + expect(agency('5')).toBe(500) + expect(agency('100')).toBe(10_000) + expect(agency('200')).toBe(20_000) + }) + + it('pays the arcade $5,000 when full, else its share of 35 slots', () => { + const arcade = (slots?: string) => + dailyPayout( + safe('arcade'), + owning('arcade', slots ? { 'floor-slots': slots } : {}), + property('arcade'), + ) + expect(arcade()).toBe(5_000) + expect(arcade('full')).toBe(5_000) + expect(arcade('14')).toBe(2_000) + expect(arcade('0')).toBe(0) + }) + + 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: false, popularity: false }) + expect(parts('arcade', { 'floor-slots': '20' })).toEqual({ payout: true, popularity: false }) + 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'), 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('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 popularity falls', () => { + // 50k at 100%, then 45k at 90%, 24k at 80%, 22k at 70%: full on the 4th payout. + const state = { money: 120_000, sinceMs: 0, popularity: 100 } + expect(fullInMs(state, ...nightclub)).toBe(4 * DAY_MS) + // Down to $1,500 a day at 0%: 10 payouts make $185,100, then 30 more at $1,500. + expect(fullInMs({ ...state, money: 21_000 }, ...nightclub)).toBe(40 * 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 agency = [safe('agency'), owning('agency'), property('agency')] as const + expect(fullInMs({ money: 0, sinceMs: 0 }, ...agency)).toBeNull() + }) +}) + +describe('advanceSafes', () => { + it('pays owned safes only, starting empty', () => { + const owned = { ...owning('arcade'), 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..84a4046 --- /dev/null +++ b/src/lib/safes.ts @@ -0,0 +1,205 @@ +// 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 count = Number(upgrade(owned, property, payout.upgrade)) + return Math.min(payout.max, Math.floor(count / payout.stepSize) * payout.perStep) + } + case 'slots': { + const filled = upgrade(owned, property, payout.upgrade) + if (filled === 'full') return payout.full + return Math.round((payout.full * Number(filled)) / payout.slots) + } + case 'fixed': + return payout.withUpgrade && upgrade(owned, property, payout.withUpgrade.upgrade) === true + ? payout.withUpgrade.amount + : payout.amount + } +} + +/** 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 === 'slots' && upgrade(owned, property, payout.upgrade) !== 'full'), + 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 } +} + +/** 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, counting popularity falling day by day. 0 when full; null + * if it never fills (nothing paid, or not before payouts stop). + */ +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 loss = popularityLoss(safe, owned, property) + let { money, popularity } = state + let ms = nextPayoutInMs(state) + // Payouts never rise as popularity falls, so this ends once the safe is full or a day pays + // nothing (at most capacity / smallest payout days). + for (;;) { + const paid = dailyPayout(safe, owned, property, popularity) + if (paid <= 0) return null + money += paid + if (money >= cap) return ms + if (popularity !== undefined) popularity = Math.max(0, popularity - loss) + ms += 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' }, From 9a8677b3271f4ec842e1d613415b1e28e3f38654 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 21:09:41 +0000 Subject: [PATCH 2/4] Safes: number inputs, agency base payout, Full in at current popularity - Agency contracts and arcade floor slots are typed numbers (a new "count" upgrade kind) instead of long dropdowns. - Agency pays a $250 base plus $500 per 5 contracts (estimate). - "Full in" assumes popularity stays where it is, so it grows as it falls. - The popularity bar has five segments in the edit controls. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/016-safes.md | 15 ++++++-- docs/decisions/README.md | 2 +- src/components/Businesses.svelte | 1 + src/components/PropertySettings.svelte | 14 ++++++- src/components/SafeCard.svelte | 10 ++--- src/data/business-events.ts | 2 +- src/data/properties.ts | 30 ++++----------- src/data/safes.ts | 31 +++++++++++---- src/lib/properties.test.ts | 15 +++++++- src/lib/properties.ts | 20 ++++++++-- src/lib/safes.test.ts | 53 ++++++++++++++++---------- src/lib/safes.ts | 32 ++++++---------- 12 files changed, 135 insertions(+), 90 deletions(-) diff --git a/docs/decisions/016-safes.md b/docs/decisions/016-safes.md index 260b3df..b2084fc 100644 --- a/docs/decisions/016-safes.md +++ b/docs/decisions/016-safes.md @@ -18,13 +18,20 @@ 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". -- **Agency:** $500 per 5 security contracts (setting in steps of 5), at most $20,000; no base - payout. Step size and base are estimates (issue #40). -- **Arcade:** $5,000 with all 35 floor slots filled; fewer slots get an even share - ($5,000 × filled / 35), marked as an estimate (issue #42). A machine taking two slots counts as two. +- **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 (the default); 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). +- **"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), popularity (also diff --git a/docs/decisions/README.md b/docs/decisions/README.md index fd25b80..8d33cf8 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -21,7 +21,7 @@ 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 diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte index 06ebac8..fb9437c 100644 --- a/src/components/Businesses.svelte +++ b/src/components/Businesses.svelte @@ -194,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}`] : [] 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 index 0c71d92..91ead7e 100644 --- a/src/components/SafeCard.svelte +++ b/src/components/SafeCard.svelte @@ -2,6 +2,7 @@ // One property safe (issue #8): money, payout, next payout and time until full, and the // nightclub's popularity. Collapsed it shows only that; `editing` adds the resync controls. // Logic: lib/safes.ts. + import { BAR_SEGMENTS } from '../data/business-events' import type { Property } from '../data/properties' import type { Safe } from '../data/safes' import type { OwnedProperties } from '../lib/properties' @@ -37,7 +38,7 @@ const best = $derived(bestPayout(safe, owned, property)) const full = $derived(fullInMs(current, safe, owned, property)) const estimated = $derived(estimatedParts(safe, owned, property)) - // Popularity falls at an estimated rate, so the time to full rests on it too. + // "Full in" assumes popularity stays where it is, so it does not rest on the estimated loss. const est = (label: string, isEstimate: boolean) => (isEstimate ? `${label} (est.)` : label) const money = (value: number) => formatValue(value, 'money') // The lowest popularity that still pays the most: below it the payout is cut. @@ -68,6 +69,7 @@ label="Popularity" value={popularity} max={100} + segments={BAR_SEGMENTS.count} bare onpreview={(value) => (dragging = value)} onset={(value) => onresync({ ...current, popularity: value })} @@ -125,11 +127,7 @@ {#if full === null}

Never full: this safe gets nothing a day.

{:else} - + {/if} {#if safe.estimate} 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 13f9c8d..279688a 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -19,6 +19,8 @@ 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. */ @@ -121,18 +123,13 @@ export const PROPERTIES: readonly Property[] = [ id: 'arcade', label: 'Arcade', upgrades: [ + // A machine that takes two slots counts as two. { - kind: 'choice', + kind: 'count', id: 'floor-slots', - label: 'Floor slots filled', - // A machine that takes two slots counts as two. - options: [ - { id: 'full', label: `All ${ARCADE_SLOTS}` }, - ...Array.from({ length: ARCADE_SLOTS }, (_, i) => { - const n = String(ARCADE_SLOTS - 1 - i) - return { id: n, label: n } - }), - ], + label: `Floor slots filled (of ${ARCADE_SLOTS})`, + max: ARCADE_SLOTS, + default: ARCADE_SLOTS, }, ], card: false, @@ -142,18 +139,7 @@ export const PROPERTIES: readonly Property[] = [ { id: 'agency', label: 'Agency', - upgrades: [ - { - kind: 'choice', - id: 'contracts', - label: 'Security contracts done', - // The payout rises every 5 contracts and stops at 200. - options: Array.from({ length: 41 }, (_, i) => ({ - id: String(i * 5), - label: i === 40 ? '200 or more' : `${i * 5} to ${i * 5 + 4}`, - })), - }, - ], + upgrades: [{ kind: 'count', id: 'contracts', label: 'Security contracts done', default: 0 }], card: false, source: SAFES.find((safe) => safe.propertyId === 'agency')!.source, checked: SAFES_CHECKED, diff --git a/src/data/safes.ts b/src/data/safes.ts index dafd900..7b2436b 100644 --- a/src/data/safes.ts +++ b/src/data/safes.ts @@ -14,11 +14,18 @@ export type SafePayout = /** By popularity: the row with the highest `from` at or below it. */ | { kind: 'popularity'; rows: readonly PayoutRow[] } /** - * `perStep` for each full step of a choice upgrade whose option ids are counts, up to `max`; - * e.g. $500 per 5 security contracts. + * `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; perStep: number; stepSize: number; max: number } - /** `full` with every floor slot filled (option "full"), else that share of the `slots`. */ + | { + 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 } } @@ -126,14 +133,22 @@ export const SAFES: readonly Safe[] = [ { propertyId: 'agency', capacity: 250_000, - payout: { kind: 'steps', upgrade: 'contracts', perStep: 500, stepSize: 5, max: 20_000 }, + payout: { + kind: 'steps', + upgrade: 'contracts', + base: 250, + perStep: 500, + stepSize: 5, + max: 20_000, + }, estimate: - 'The payout per contract is a best estimate: $500 for every 5 security contracts, ' + - 'no base payout.', + '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. Step and base payout unconfirmed (issue #40).', + 'How to complete Security Contracts. Base $250: sportskeeda, GTA Online Agency passive ' + + 'income guide. Step and base payout unconfirmed (issue #40).', 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.test.ts b/src/lib/safes.test.ts index 9f7ea55..265367d 100644 --- a/src/lib/safes.test.ts +++ b/src/lib/safes.test.ts @@ -48,26 +48,35 @@ describe('dailyPayout', () => { expect(dailyPayout(...nightclub, 3)).toBe(1_500) }) - it('pays the agency $500 per 5 contracts, up to $20,000', () => { - const agency = (contracts: string) => - dailyPayout(safe('agency'), owning('agency', { contracts }), property('agency')) - expect(agency('0')).toBe(0) - expect(agency('5')).toBe(500) - expect(agency('100')).toBe(10_000) - expect(agency('200')).toBe(20_000) + 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?: string) => + const arcade = (slots?: number) => dailyPayout( safe('arcade'), - owning('arcade', slots ? { 'floor-slots': slots } : {}), + owning('arcade', slots === undefined ? {} : { 'floor-slots': slots }), property('arcade'), ) expect(arcade()).toBe(5_000) - expect(arcade('full')).toBe(5_000) - expect(arcade('14')).toBe(2_000) - expect(arcade('0')).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: all filled. + expect(arcade(40)).toBe(5_000) }) it('pays the salvage yard its 0% reputation amount, more with Staff', () => { @@ -108,7 +117,7 @@ describe('upgrades', () => { expect(parts('nightclub')).toEqual({ payout: false, popularity: true }) expect(parts('agency')).toEqual({ payout: true, popularity: false }) expect(parts('arcade')).toEqual({ payout: false, popularity: false }) - expect(parts('arcade', { 'floor-slots': '20' })).toEqual({ payout: true, popularity: false }) + expect(parts('arcade', { 'floor-slots': 20 })).toEqual({ payout: true, popularity: false }) expect(parts('salvage-yard').payout).toBe(true) }) }) @@ -180,19 +189,23 @@ describe('forecasts', () => { expect(nextPayoutInMs({ money: 0, sinceMs: 30 * MIN })).toBe(18 * MIN) }) - it('counts the days until full as popularity falls', () => { - // 50k at 100%, then 45k at 90%, 24k at 80%, 22k at 70%: full on the 4th payout. + 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(4 * DAY_MS) - // Down to $1,500 a day at 0%: 10 payouts make $185,100, then 30 more at $1,500. - expect(fullInMs({ ...state, money: 21_000 }, ...nightclub)).toBe(40 * DAY_MS) + 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 agency = [safe('agency'), owning('agency'), property('agency')] as const - expect(fullInMs({ money: 0, sinceMs: 0 }, ...agency)).toBeNull() + const arcade = [ + safe('arcade'), + owning('arcade', { 'floor-slots': 0 }), + property('arcade'), + ] as const + expect(fullInMs({ money: 0, sinceMs: 0 }, ...arcade)).toBeNull() }) }) diff --git a/src/lib/safes.ts b/src/lib/safes.ts index 84a4046..07b06bb 100644 --- a/src/lib/safes.ts +++ b/src/lib/safes.ts @@ -48,13 +48,12 @@ export function dailyPayout( case 'popularity': return payout.rows.find((row) => popularity >= row.from)?.amount ?? 0 case 'steps': { - const count = Number(upgrade(owned, property, payout.upgrade)) - return Math.min(payout.max, Math.floor(count / payout.stepSize) * payout.perStep) + 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 = upgrade(owned, property, payout.upgrade) - if (filled === 'full') return payout.full - return Math.round((payout.full * Number(filled)) / payout.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 @@ -91,7 +90,7 @@ export function estimatedParts( payout: payout.kind === 'steps' || payout.kind === 'fixed' || - (payout.kind === 'slots' && upgrade(owned, property, payout.upgrade) !== 'full'), + (payout.kind === 'slots' && Number(upgrade(owned, property, payout.upgrade)) < payout.slots), popularity: safe.popularity !== undefined, } } @@ -127,8 +126,8 @@ export function nextPayoutInMs(state: SafeState): number { } /** - * Play time until the safe is full, counting popularity falling day by day. 0 when full; null - * if it never fills (nothing paid, or not before payouts stop). + * 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, @@ -138,19 +137,10 @@ export function fullInMs( ): number | null { const cap = capacity(safe, owned, property) if (state.money >= cap) return 0 - const loss = popularityLoss(safe, owned, property) - let { money, popularity } = state - let ms = nextPayoutInMs(state) - // Payouts never rise as popularity falls, so this ends once the safe is full or a day pays - // nothing (at most capacity / smallest payout days). - for (;;) { - const paid = dailyPayout(safe, owned, property, popularity) - if (paid <= 0) return null - money += paid - if (money >= cap) return ms - if (popularity !== undefined) popularity = Math.max(0, popularity - loss) - ms += DAY_MS - } + 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 } /** From df64dc304f32d9c32c7062beedf9a27aea9b234a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:18:17 +0000 Subject: [PATCH 3/4] Safes: payout sync button, Garment Factory safe, arcade slots default 0 - Replace the typed "Next payout in" with a "Payout just arrived" button: restarts the day and pays the payout if LSOS had not counted it yet (syncPayout). - Nightclub "Payout cut" names the current popularity instead of implying one cut amount. - Arcade floor slots default to 0 like every other count; note says "fewer than all 35". - Garment Factory safe: random $1,500 to $2,000 a day, $100,000 cap (gta.wiki); counted at the middle, marked as an estimate. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/016-safes.md | 19 ++++++++++---- docs/decisions/README.md | 2 +- src/components/SafeCard.svelte | 42 ++++++++++++++++++++----------- src/data/properties.ts | 10 +++++++- src/data/safes.ts | 18 +++++++++++-- src/lib/safes.test.ts | 46 +++++++++++++++++++++++++++++----- src/lib/safes.ts | 18 +++++++++++++ 7 files changed, 125 insertions(+), 30 deletions(-) diff --git a/docs/decisions/016-safes.md b/docs/decisions/016-safes.md index b2084fc..2369ea3 100644 --- a/docs/decisions/016-safes.md +++ b/docs/decisions/016-safes.md @@ -5,7 +5,7 @@ ## Decision - **Own tab.** Safes get a "Safes" tab instead of more on the Businesses cards: Nightclub, - Agency, Arcade and Salvage Yard. Agency, Arcade and Salvage Yard join the catalog with + 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 @@ -17,25 +17,34 @@ 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". + 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 (the default); fewer slots get an even +- **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), popularity (also - a slider, with the boosts as presets) and the time to the next payout. +- **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 diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8d33cf8..d6d9174 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -26,4 +26,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [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 paid per in-game day of play time, nightclub popularity with estimated loss and boosts +- [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/components/SafeCard.svelte b/src/components/SafeCard.svelte index 91ead7e..7321df5 100644 --- a/src/components/SafeCard.svelte +++ b/src/components/SafeCard.svelte @@ -11,12 +11,13 @@ bestPayout, capacity, dailyPayout, - DAY_MS, estimatedParts, fullInMs, nextPayoutInMs, + syncPayout, type SafeState, } from '../lib/safes' + import Button from './ui/Button.svelte' import Countdown from './ui/Countdown.svelte' import EditableValue from './ui/EditableValue.svelte' import Meter from './ui/Meter.svelte' @@ -26,7 +27,7 @@ property: Property owned: OwnedProperties safeState: SafeState - /** Show the controls to correct money, popularity and the next payout. */ + /** Show the controls to correct money and popularity and to sync the payout time. */ editing: boolean onresync: (safeState: SafeState) => void } @@ -41,6 +42,12 @@ // "Full in" assumes popularity stays where it is, so it does not rest on the estimated loss. const est = (label: string, isEstimate: boolean) => (isEstimate ? `${label} (est.)` : label) const money = (value: number) => formatValue(value, 'money') + // A random payout shows its range; LSOS counts the middle. + const payoutText = $derived( + safe.payout.kind === 'range' + ? `${money(safe.payout.min)} to ${money(safe.payout.max)} a day` + : `${money(payout)} a day`, + ) // The lowest popularity that still pays the most: below it the payout is cut. const maxFrom = $derived(safe.payout.kind === 'popularity' ? safe.payout.rows[0]!.from : 0) const popularity = $derived(current.popularity) @@ -80,8 +87,8 @@ {/if} {#if payout < best}

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

{/if} {/if} @@ -99,14 +106,6 @@ ]} onresync={(value) => onresync({ ...current, money: value })} /> - onresync({ ...current, sinceMs: DAY_MS - value })} - /> {:else} {est('Payout', estimated.payout)} - {money(payout)} a day + {payoutText}
-{#if !editing} - + +{#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.

@@ -158,6 +163,13 @@ font-variant-numeric: tabular-nums; } + .sync { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); + } + .warn { border-left: 3px solid var(--warn); font-size: var(--text-sm); diff --git a/src/data/properties.ts b/src/data/properties.ts index 279688a..ff291ae 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -129,7 +129,7 @@ export const PROPERTIES: readonly Property[] = [ id: 'floor-slots', label: `Floor slots filled (of ${ARCADE_SLOTS})`, max: ARCADE_SLOTS, - default: ARCADE_SLOTS, + default: 0, }, ], card: false, @@ -155,6 +155,14 @@ export const PROPERTIES: readonly Property[] = [ source: SAFES.find((safe) => safe.propertyId === 'salvage-yard')!.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, + }, ] function mc(id: string, label: string): Property { diff --git a/src/data/safes.ts b/src/data/safes.ts index 7b2436b..b32ff69 100644 --- a/src/data/safes.ts +++ b/src/data/safes.ts @@ -29,6 +29,8 @@ export type SafePayout = | { 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 { @@ -156,8 +158,8 @@ export const SAFES: readonly Safe[] = [ capacity: 100_000, payout: { kind: 'slots', upgrade: 'floor-slots', full: 5_000, slots: ARCADE_SLOTS }, estimate: - 'With empty floor slots the payout is a best estimate: $5,000 shared evenly over the ' + - '35 slots.', + '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; ' + @@ -178,4 +180,16 @@ export const SAFES: readonly Safe[] = [ 'gtaboss, How to get completely passive income. Open questions: issue #43.', 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, + }, ] diff --git a/src/lib/safes.test.ts b/src/lib/safes.test.ts index 265367d..37e7cfa 100644 --- a/src/lib/safes.test.ts +++ b/src/lib/safes.test.ts @@ -15,6 +15,7 @@ import { nextPayoutInMs, parseSafes, popularityLoss, + syncPayout, } from './safes' const MIN = 60_000 @@ -71,12 +72,18 @@ describe('dailyPayout', () => { owning('arcade', slots === undefined ? {} : { 'floor-slots': slots }), property('arcade'), ) - expect(arcade()).toBe(5_000) + 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: all filled. - expect(arcade(40)).toBe(5_000) + // 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', () => { @@ -116,8 +123,10 @@ describe('upgrades', () => { 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: false, 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) }) }) @@ -169,7 +178,11 @@ describe('advanceSafe', () => { }) it('has no popularity for other safes', () => { - const arcade = [safe('arcade'), owning('arcade'), property('arcade')] as const + 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, @@ -182,6 +195,24 @@ describe('advanceSafe', () => { }) }) +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 @@ -211,7 +242,10 @@ describe('forecasts', () => { describe('advanceSafes', () => { it('pays owned safes only, starting empty', () => { - const owned = { ...owning('arcade'), agency: { owned: false, upgrades: {} } } + 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 }, }) diff --git a/src/lib/safes.ts b/src/lib/safes.ts index 07b06bb..458f637 100644 --- a/src/lib/safes.ts +++ b/src/lib/safes.ts @@ -59,6 +59,8 @@ export function dailyPayout( 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) } } @@ -90,6 +92,7 @@ export function estimatedParts( 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, } @@ -120,6 +123,21 @@ export function advanceSafe( 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) From aa8c299909583d5a632f79b7b3709efa7921bdee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:25:38 +0000 Subject: [PATCH 4/4] Safes: order cards by fastest fill, Salvage Yard last Co-Authored-By: Claude Opus 5.5 --- src/data/properties.ts | 16 ++++++++-------- src/data/safes.ts | 25 +++++++++++++------------ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/data/properties.ts b/src/data/properties.ts index ff291ae..e7056d1 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -144,6 +144,14 @@ export const PROPERTIES: readonly Property[] = [ 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', @@ -155,14 +163,6 @@ export const PROPERTIES: readonly Property[] = [ source: SAFES.find((safe) => safe.propertyId === 'salvage-yard')!.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, - }, ] function mc(id: string, label: string): Property { diff --git a/src/data/safes.ts b/src/data/safes.ts index b32ff69..e07b766 100644 --- a/src/data/safes.ts +++ b/src/data/safes.ts @@ -70,6 +70,7 @@ 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', @@ -166,6 +167,18 @@ export const SAFES: readonly Safe[] = [ '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, @@ -180,16 +193,4 @@ export const SAFES: readonly Safe[] = [ 'gtaboss, How to get completely passive income. Open questions: issue #43.', 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, - }, ]