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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ Svelte 5 + TypeScript + Vite. Public repository.
| Saved user state, migrations, export/import | `src/lib/state.ts`, `src/lib/store.svelte.ts` |
| UI components (thin) | `src/components/` |
| Base UI kit (panel, meter, button...), preview | `src/components/ui/`, `#/kit` in dev (dec. 009) |
| Resync values; business bars (Sold, Raided) | `ui/EditableValue`, `ui/BusinessBars` (dec. 010) |
| Resync values, editable bars (dec. 010, 018) | `ui/EditableValue`, `EditableBar`, `BusinessBars` |
| Game data with sources | `src/data/` (decision 004) |
| Owned properties and upgrades | `data/properties.ts`, `lib/properties.ts` (011) |
| 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) |
| Acid Lab, production boost | `data/production.ts`, `lib/production.ts` (017) |
| Money Fronts safe and heat | `data/fronts.ts`, `lib/fronts.ts` (018) |
| Theme tokens | `src/app.css` (decision 009) |
| CI, templates | `.github/` |
| Skills: `handoff`, `finish-pr`; agent: `scout` | `.claude/` |
Expand Down
44 changes: 44 additions & 0 deletions docs/decisions/018-money-fronts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 018: Money Fronts safe and heat

2026-10-15, issue #11. Builds on decisions 004, 010, 011, 013 and 016.

## Decision

- **One card on the Safes tab,** not its own tab or the Businesses tab. The game pays all three
fronts into one safe in the Hands On Car Wash, so a tab would hold a single card, and the safe
works like the others (decision 016). The card shows a heat bar per owned front, the safe, the
payout, the next payout, time until full and time until the Car Wash reaches 100% heat.
- **Properties:** the three fronts join the catalog with `card: false` and a "Laundering
missions done" count (15 doubles that front's pay). Vehicle Warehouse, Auto Shop, Bail
Enforcement Office and McKenzie Field Hangar join as owned-only properties because they add
to the safe. The Special Cargo Warehouse gets a "Largest owned" choice (default Large): the
game counts only the largest warehouse. The card shows only while the Car Wash is owned.
- **Payout** (`src/data/fronts.ts`), every in-game day (48 minutes of play time), up to
$100,000: $750 per front ($1,500 after 15 laundering missions) plus a fixed amount for each
other business owned, from the in-game safe screen (data comment has the sources). A front at
100% heat pays nothing; the Car Wash at 100% stops the whole safe. LSOS counts every owned
business; the game skips biker businesses and the Bunker while shut down or not set up, which
the card says (shutting down: issue #38).
- **Heat** per front, saved with the safe (`heat` by front id, data version 9). At the end of
each day, after the payout, every front below 100% gains 10%, one less per other front below
100% (8% with all three). A laundering mission or Mr. Faber job adds 10% to the front it was
launched from; a laundering mission counts toward that front's 15, a Faber job toward all
three. Legal work clears the front's heat. The rise comes from a player's notes, and which
fronts rise is LSOS's reading of them, so heat is marked "(est.)" (decision 013).
- **One countdown, whichever comes first:** "Full in", assuming heat stays where it is now
(like popularity on the other safes), or "Car Wash max heat in (est.)" when the Car Wash would
reach 100% and stop the safe sooner.
- **Heat bars** are amber, red at 100% like the in-game bar (Meter `heat` kind).
- **Dragging a bar** shows the value under the pointer while dragging, on every editable
percentage bar (stock, supplies, popularity, heat): one `ui/EditableBar` component.
- **Edit mode** adds the resync controls (decision 010: heat values and bars, safe money with
Collected/Full, "Payout just arrived" with the half-day rule of decision 016) and per front
"Laundering +10%", "Faber job +10%" and "Legal job" buttons.
- **Data version 9** with an unchanged migration: an older LSOS would silently drop the heat.

## Why

Following the game's own shape (one safe, heat per front) keeps the card small and the numbers
exact where sources are exact. Logging missions in one click keeps both heat and the doubling
count right without typing. Full-in on today's heat stays exact and matches decision 016; the
heat countdown covers the risk that the safe stops.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m
- [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
- [017](017-production-boost.md) Acid Lab: optional far sale, value bonus (named product), no raids; production boost by units made or 24 real hours, once a day
- [018](018-money-fronts.md) Money Fronts: one Car Wash safe card on the Safes tab, paid by the fronts and other businesses, heat per front (est.), missions and legal jobs
203 changes: 203 additions & 0 deletions src/components/MoneyFrontsCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<script lang="ts">
// The Money Fronts safe in the Car Wash (issue #11): money, payout, next payout, time until
// full (or until the Car Wash stops it, if sooner), and a heat bar per owned front. `editing` adds the resync controls and the mission and
// legal-job buttons. Logic: lib/fronts.ts.
import { CAR_WASH, FRONTS_CAPACITY, FRONTS_ESTIMATE, HEAT_PER_MISSION } from '../data/fronts'
import type { Property } from '../data/properties'
import {
frontPay,
fullInMs,
heatOf,
legalJob,
MAX_HEAT,
maxHeatInMs,
mission,
ownedFronts,
payoutParts,
setHeat,
syncFronts,
} from '../lib/fronts'
import type { OwnedProperties } from '../lib/properties'
import { formatValue } from '../lib/resync'
import { nextPayoutInMs, type SafeState } from '../lib/safes'
import Button from './ui/Button.svelte'
import Countdown from './ui/Countdown.svelte'
import EditableBar from './ui/EditableBar.svelte'
import EditableValue from './ui/EditableValue.svelte'
import Meter from './ui/Meter.svelte'

interface Props {
catalog: readonly Property[]
owned: OwnedProperties
onchange: (owned: OwnedProperties) => void
safeState: SafeState
/** Show the controls to correct money and heat, log missions and sync the payout time. */
editing: boolean
onresync: (safeState: SafeState) => void
}

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

const fronts = $derived(ownedFronts(owned))
const parts = $derived(payoutParts(current, owned, catalog))
// "Full in" assumes heat stays where it is, like popularity on the other safes.
const full = $derived(fullInMs(current, owned, catalog))
const stopsIn = $derived(maxHeatInMs(current, owned, CAR_WASH))
// Whichever comes first: the safe filling up, or the Car Wash stopping it at 100% heat.
const stopsFirst = $derived(full !== null && stopsIn !== null && stopsIn < full)
const money = (value: number) => formatValue(value, 'money')
const stopped = $derived(fronts.filter((f) => heatOf(current, f.propertyId) >= MAX_HEAT))
const carWashStopped = $derived(stopped.some((f) => f.propertyId === CAR_WASH))

function log(frontId: string, kind: 'laundering' | 'faber') {
const next = mission(current, owned, catalog, frontId, kind)
onchange(next.owned)
onresync(next.state)
}
</script>

{#each fronts as front (front.propertyId)}
{@const heat = heatOf(current, front.propertyId)}
{#if editing}
<EditableBar
label="{front.label} heat (est.)"
value={heat}
kind="heat"
segments={MAX_HEAT / HEAT_PER_MISSION}
onresync={(value) => onresync(setHeat(current, front.propertyId, value))}
/>
<div class="actions">
<Button
title="A laundering mission launched here: +{HEAT_PER_MISSION}% heat, counts toward doubling this front's pay"
onclick={() => log(front.propertyId, 'laundering')}>Laundering +{HEAT_PER_MISSION}%</Button
>
<Button
title="A Mr. Faber job launched here: +{HEAT_PER_MISSION}% heat, counts toward doubling every front's pay"
onclick={() => log(front.propertyId, 'faber')}>Faber job +{HEAT_PER_MISSION}%</Button
>
<Button
title="Legal work here (washing cars, a helitour, a weed delivery) clears the heat"
onclick={() => onresync(legalJob(current, front.propertyId))}>Legal job</Button
>
</div>
{:else}
<Meter
label="{front.label} heat (est.)"
value={heat}
max={MAX_HEAT}
kind="heat"
segments={MAX_HEAT / HEAT_PER_MISSION}
/>
{/if}
{/each}

{#if carWashStopped}
<p class="warn" role="note">
<strong>Safe stopped.</strong> The Car Wash is at 100% heat: the safe gets nothing until you do a
legal job there.
</p>
{:else if stopped.length > 0}
<p class="warn" role="note">
<strong>Front stopped.</strong>
{stopped.map((f) => `${f.label} (${money(frontPay(f, owned, catalog))})`).join(', ')}
{stopped.length === 1 ? 'is' : 'are'} at 100% heat and pay nothing until a legal job there.
</p>
{/if}

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

<div class="facts">
<span class="label">Payout</span>
<span class="value">{money(parts.fronts + parts.others)} a day</span>
</div>
<p class="hint">
Fronts {money(parts.fronts)} + your other businesses {money(parts.others)}. Biker businesses and
the Bunker only count while set up and not shut down.
</p>

<Countdown label="Next payout in" remainingMs={nextPayoutInMs(current)} />
{#if editing}
<div class="actions">
<Button onclick={() => onresync(syncFronts(current, owned, catalog))}
>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 if stopsFirst}
<Countdown label="Car Wash max heat in (est.)" remainingMs={stopsIn ?? 0} />
<p class="hint">The safe stops before it is full unless you do a legal job at the Car Wash.</p>
{:else}
<Countdown label="Full in" remainingMs={full} readyText="Full" />
{/if}

<p class="hint" role="note">{FRONTS_ESTIMATE}</p>

<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;
}

.actions {
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>
22 changes: 5 additions & 17 deletions src/components/SafeCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
} from '../lib/safes'
import Button from './ui/Button.svelte'
import Countdown from './ui/Countdown.svelte'
import EditableBar from './ui/EditableBar.svelte'
import EditableValue from './ui/EditableValue.svelte'
import Meter from './ui/Meter.svelte'

Expand Down Expand Up @@ -51,17 +52,14 @@
// 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
<EditableBar
label={est('Popularity', estimated.popularity)}
value={dragging ?? popularity}
unit="percent"
min={0}
max={100}
value={popularity}
segments={BAR_SEGMENTS.count}
presets={[
{ label: 'Maxed', value: 100, title: 'Full bar, e.g. after switching to a new DJ' },
...(safe.popularity?.boosts ?? []).map((boost) => ({
Expand All @@ -71,17 +69,7 @@
})),
]}
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}
Expand Down
46 changes: 41 additions & 5 deletions src/components/Safes.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
<script lang="ts">
// The safes of owned properties (issue #8), each with a gear to change what is owned and
// upgraded (decision 011). Logic: lib/safes.ts.
// The safes of owned properties (issue #8) and the Money Fronts safe (issue #11), each with a
// gear to change what is owned and upgraded (decision 011). Logic: lib/safes.ts, lib/fronts.ts.
import { CAR_WASH, FRONTS } from '../data/fronts'
import type { Property } from '../data/properties'
import type { Safe } from '../data/safes'
import { isOwned, type OwnedProperties } from '../lib/properties'
import { emptyFronts } from '../lib/fronts'
import { emptySafe, type SafesById, type SafeState } from '../lib/safes'
import { hashForTab } from '../lib/tabs'
import PropertySettings from './PropertySettings.svelte'
import MoneyFrontsCard from './MoneyFrontsCard.svelte'
import SafeCard from './SafeCard.svelte'
import Button from './ui/Button.svelte'
import Panel from './ui/Panel.svelte'
Expand All @@ -30,18 +33,51 @@
return property && isOwned(owned, property.id) ? [{ safe, property }] : []
}),
)
const fronts = $derived(isOwned(owned, CAR_WASH))
const frontProperties = $derived(
FRONTS.flatMap((front) => catalog.filter((p) => p.id === front.propertyId)),
)
let open = $state<string | null>(null)
let editing = $state<string | null>(null)
</script>

{#if shown.length === 0}
{#if shown.length === 0 && !fronts}
<p class="empty">
You do not own a property with a safe yet: {safes
.map((safe) => catalog.find((p) => p.id === safe.propertyId)?.label)
You do not own a property with a safe yet: {[CAR_WASH, ...safes.map((safe) => safe.propertyId)]
.map((id) => catalog.find((p) => p.id === id)?.label)
.join(', ')}. Mark what you own in <a href={hashForTab('settings')}>Settings</a>.
</p>
{:else}
<div class="grid">
{#if fronts}
<Panel title="Money Fronts">
{#if open === CAR_WASH}
{#each frontProperties as property (property.id)}
<PropertySettings {property} {owned} {onchange} />
{/each}
{/if}
<MoneyFrontsCard
{catalog}
{owned}
{onchange}
safeState={stored[CAR_WASH] ?? emptyFronts()}
editing={editing === CAR_WASH}
onresync={(state) => onresync(CAR_WASH, state)}
/>
{#snippet footer()}
<Button
aria-expanded={editing === CAR_WASH}
onclick={() => (editing = editing === CAR_WASH ? null : CAR_WASH)}
>{editing === CAR_WASH ? 'Done' : 'Edit'}</Button
>
<SettingsButton
label="Money Fronts"
open={open === CAR_WASH}
onclick={() => (open = open === CAR_WASH ? null : CAR_WASH)}
/>
{/snippet}
</Panel>
{/if}
{#each shown as { safe, property } (property.id)}
<Panel title={property.label}>
{#if open === property.id}
Expand Down
Loading
Loading