Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Expand Down
54 changes: 54 additions & 0 deletions docs/decisions/016-safes.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -61,6 +64,15 @@
onnightclub={(id, crates) =>
(store.state.nightclub = { ...store.state.nightclub, [id]: crates })}
/>
{:else if active === 'safes'}
<Safes
safes={SAFES}
catalog={PROPERTIES}
owned={store.state.properties}
onchange={(owned) => (store.state.properties = owned)}
stored={store.state.safes}
onresync={(id, safe) => (store.state.safes = { ...store.state.safes, [id]: safe })}
/>
{:else if active === 'settings'}
<Settings />
{:else}
Expand Down
21 changes: 6 additions & 15 deletions src/components/Businesses.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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}`] : []
Expand Down Expand Up @@ -260,18 +262,11 @@
>{editing === property.id ? 'Done' : 'Edit'}</Button
>
{/if}
<Button
aria-label="Settings for {property.label}"
aria-expanded={open === property.id}
<SettingsButton
label={property.label}
open={open === property.id}
onclick={() => (open = open === property.id ? null : property.id)}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M19.14 12.94a7.07 7.07 0 0 0 0-1.88l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7 7 0 0 0-1.62-.94l-.36-2.54a.5.5 0 0 0-.5-.42h-3.84a.5.5 0 0 0-.5.42l-.36 2.54a7 7 0 0 0-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L2.71 8.84a.5.5 0 0 0 .12.64l2.03 1.58a7.07 7.07 0 0 0 0 1.88l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32a.5.5 0 0 0 .6.22l2.39-.96a7 7 0 0 0 1.62.94l.36 2.54a.5.5 0 0 0 .5.42h3.84a.5.5 0 0 0 .5-.42l.36-2.54a7 7 0 0 0 1.62-.94l2.39.96a.5.5 0 0 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.64zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z"
/>
</svg>
</Button>
/>
{/snippet}
</Panel>
</div>
Expand Down Expand Up @@ -342,8 +337,4 @@
color: var(--muted);
font-size: var(--text-sm);
}

svg {
display: block;
}
</style>
14 changes: 13 additions & 1 deletion src/components/PropertySettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,6 +48,16 @@
/>
{upgrade.label}
</label>
{:else if upgrade.kind === 'count'}
<div class="count">
<NumberField
label={upgrade.label}
value={upgradeValue(owned, property.id, upgrade) as number}
min={0}
max={upgrade.max}
onchange={(value) => onchange(setUpgrade(owned, property.id, upgrade.id, value))}
/>
</div>
{:else}
<div class="choice">
<label for="{id}-{upgrade.id}">{upgrade.label}</label>
Expand Down Expand Up @@ -106,7 +117,8 @@
padding-left: var(--space-3);
}

.upgrades > label {
.upgrades > label,
.count {
grid-column: 1 / -1;
}

Expand Down
187 changes: 187 additions & 0 deletions src/components/SafeCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<script lang="ts">
// 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'
import { formatValue } from '../lib/resync'
import {
bestPayout,
capacity,
dailyPayout,
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'

interface Props {
safe: Safe
property: Property
owned: OwnedProperties
safeState: SafeState
/** Show the controls to correct money and popularity and to sync the payout time. */
editing: boolean
onresync: (safeState: SafeState) => void
}

let { safe, property, owned, safeState: current, editing, onresync }: Props = $props()

const cap = $derived(capacity(safe, owned, property))
const payout = $derived(dailyPayout(safe, owned, property, current.popularity))
const best = $derived(bestPayout(safe, owned, property))
const full = $derived(fullInMs(current, safe, owned, property))
const estimated = $derived(estimatedParts(safe, owned, property))
// "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)
let dragging = $state<number | null>(null)
</script>

{#if popularity !== undefined}
{#if editing}
<EditableValue
label={est('Popularity', estimated.popularity)}
value={dragging ?? popularity}
unit="percent"
min={0}
max={100}
presets={[
{ label: 'Maxed', value: 100, title: 'Full bar, e.g. after switching to a new DJ' },
...(safe.popularity?.boosts ?? []).map((boost) => ({
label: boost.label,
value: Math.min(100, popularity + boost.adds),
title: boost.title,
})),
]}
onresync={(value) => onresync({ ...current, popularity: value })}
>
<Meter
label="Popularity"
value={popularity}
max={100}
segments={BAR_SEGMENTS.count}
bare
onpreview={(value) => (dragging = value)}
onset={(value) => onresync({ ...current, popularity: value })}
/>
</EditableValue>
{:else}
<Meter label={est('Popularity', estimated.popularity)} value={popularity} max={100} />
{/if}
{#if payout < best}
<p class="warn" role="note">
<strong>Payout cut.</strong> At {popularity}% popularity the safe gets {money(payout)} a day;
{maxFrom}% or more pays {money(best)}.
</p>
{/if}
{/if}

{#if editing}
<EditableValue
label="Safe"
value={current.money}
unit="money"
min={0}
max={cap}
presets={[
{ label: 'Collected', value: 0 },
{ label: 'Full', value: cap },
]}
onresync={(value) => onresync({ ...current, money: value })}
/>
{:else}
<Meter
label="Safe"
value={current.money}
max={cap}
valueText="{money(current.money)} / {money(cap)}"
/>
{/if}

<div class="facts">
<span class="label">{est('Payout', estimated.payout)}</span>
<span class="value">{payoutText}</span>
</div>

<Countdown label="Next payout in" remainingMs={nextPayoutInMs(current)} />
{#if editing}
<div class="sync">
<Button onclick={() => onresync(syncPayout(current, safe, owned, property))}
>Payout just arrived</Button
>
<p class="hint">Press when the game says money was added to this safe.</p>
</div>
{/if}
{#if full === null}
<p class="hint">Never full: this safe gets nothing a day.</p>
{:else}
<Countdown label={est('Full in', estimated.payout)} remainingMs={full} readyText="Full" />
{/if}

{#if safe.estimate}
<p class="hint" role="note">{safe.estimate}</p>
{/if}

<style>
p {
margin: 0;
}

.facts {
align-items: baseline;
display: flex;
gap: var(--space-3);
justify-content: space-between;
}

.label {
color: var(--muted);
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: var(--tracking);
text-transform: uppercase;
}

.value {
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);
padding-left: var(--space-2);
}

.warn strong {
color: var(--warn);
}

.hint {
color: var(--muted);
font-size: var(--text-xs);
}
</style>
Loading
Loading