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 @@ -24,6 +24,7 @@ Svelte 5 + TypeScript + Vite. Public repository.
| 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) |
| Theme tokens | `src/app.css` (decision 009) |
| CI, templates | `.github/` |
| Skills: `handoff`, `finish-pr`; agent: `scout` | `.claude/` |
Expand Down
28 changes: 28 additions & 0 deletions docs/decisions/017-production-boost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 017: Acid Lab and production boosts

2026-10-15, issue #9. Builds on decisions 010, 011 and 012.

## Decision

- **Acid Lab** runs on the business production of decision 012: one rate without and one with
the equipment upgrade, all values sourced. Supply use with the upgrade comes from the cost to
fill the lab (GTABoom: $96,000, 1.6 full bars at $60,000), not from an estimate.
- **Optional per-business data** in `src/data/production.ts`: `farSale` (left out: sales pay the
same anywhere, one value shown), `valueBonus` (a toggle upgrade that adds to the value, here a
named product, +5%; the game pays whole dollars per unit), `raids: false` (no "Raided" button)
and `boost`.
- **Production boost:** started by the user with "Boost started". It runs production at the
rate's `boostedMinutesPerUnit` until it has made `units` (80) or `maxRealMs` (24 hours) of real
time has passed. It counts units made, so it waits while there are no supplies or the stock is
full. Forecasts make the boosted units first.
- **Saved state** (version 8): `boosts` maps a property id to its last boost (wall-clock start,
units left). It stays after the boost ends so the card can show "Next boost in" until the
daily reset (6:00 UTC); until then "Boost started" is hidden. "Undo" removes a boost started by
mistake; production already counted stays and can be resynced.
- Units left are not resyncable: the game does not show them.

## Why

The boost counts units, not time, so it is tracked by units made inside the same production
step; the 24 hours are real time, like deliveries, and split a stretch the same way. A separate
`boosts` section keeps `Bars`, which `BusinessBars` edits and undoes, unchanged.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m
- [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
- [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
5 changes: 5 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
now={clocks.now}
onresync={(id, bars) =>
(store.state.businesses = { ...store.state.businesses, [id]: bars })}
boosts={store.state.boosts}
onboost={(id, boost) => {
const { [id]: _, ...rest } = store.state.boosts
store.state.boosts = boost ? { ...rest, [id]: boost } : rest
}}
order={store.state.businessOrder}
onorder={(order) => (store.state.businessOrder = order)}
goods={GOODS}
Expand Down
79 changes: 66 additions & 13 deletions src/components/BusinessProduction.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
<script lang="ts">
// Stock, supplies, value and what comes next for one business (issue #6). Collapsed it shows
// only that; `editing` adds the resync controls. Logic: lib/production.ts.
// only that; `editing` adds the resync controls. A business with a production boost shows it
// and lets the user start it (issue #9). Logic: lib/production.ts.
import { SUPPLY_PURCHASE } from '../data/business-events'
import type { Production, ProductionRate } from '../data/production'
import {
boostRunning,
estimatedParts,
forecast,
nextBoostAt,
startBoost,
stockUnits,
stockValue,
type Boost,
type KnownRate,
} from '../lib/production'
import { formatValue, settleBars, type Bars } from '../lib/resync'
import BusinessBars from './ui/BusinessBars.svelte'
import Button from './ui/Button.svelte'
import Countdown from './ui/Countdown.svelte'
import Meter from './ui/Meter.svelte'

Expand All @@ -27,14 +33,35 @@
/** Show the controls to correct the bars and record sales, raids and supplies. */
editing: boolean
onresync: (bars: Bars) => void
/** What bought upgrades add to the value, e.g. 0.05 for a named product. */
bonus?: number
/** The last production boost, if the business has one (kept after it ends). */
boost?: Boost
/** Called with the new boost, or undefined to take back one started by mistake. */
onboost?: (boost: Boost | undefined) => void
}

let { production, rate, running, bars, now, editing, onresync }: Props = $props()
let {
production,
rate,
running,
bars,
now,
editing,
onresync,
bonus = 0,
boost,
onboost = () => {},
}: Props = $props()

const current = $derived(settleBars(bars, now, SUPPLY_PURCHASE.fillsTo))
const units = $derived(stockUnits(current, production))
const value = $derived(rate ? stockValue(current, production, rate) : null)
const ahead = $derived(running ? forecast(current, production, running) : null)
const value = $derived(rate ? stockValue(current, production, rate, bonus) : null)
const boosting = $derived(boostRunning(boost, production.boost, now))
const nextBoost = $derived(production.boost ? nextBoostAt(boost, production.boost) : 0)
const ahead = $derived(
running ? forecast(current, production, running, boosting ? boost!.unitsLeft : 0) : null,
)
const percent = (value: number) => formatValue(value, 'percent')
// Numbers resting on estimated rates say so (decision 013). The value also counts units made
// at an estimated rate.
Expand All @@ -43,7 +70,7 @@
</script>

{#if editing}
<BusinessBars {bars} {now} {onresync} />
<BusinessBars {bars} {now} {onresync} raids={production.raids !== false} />
{:else}
<Meter
label={est('Stock', estimated.production)}
Expand All @@ -65,15 +92,41 @@
{#if value}
<div class="facts">
<span class="label">{est('Value', estimated.value || estimated.production)}</span>
<!-- Far sales pay more, so that is the headline; nearby is the alternative. -->
<span
class="value"
title="Sold {production.farSaleLabel}; {formatValue(value.nearby, 'money')} sold nearby"
>
{formatValue(value.far, 'money')} far
<span class="alt">· {formatValue(value.nearby, 'money')} nearby</span>
</span>
{#if value.far !== null}
<!-- Far sales pay more, so that is the headline; nearby is the alternative. -->
<span
class="value"
title="Sold {production.farSale?.label}; {formatValue(value.nearby, 'money')} sold nearby"
>
{formatValue(value.far, 'money')} far
<span class="alt">· {formatValue(value.nearby, 'money')} nearby</span>
</span>
{:else}
<span class="value">{formatValue(value.nearby, 'money')}</span>
{/if}
</div>
{/if}

{#if production.boost && running?.boostedMinutesPerUnit !== undefined}
<!-- Play time while it runs; the next one is a real-time wait (daily reset). -->
<div class="facts">
{#if boosting}
<span class="label">Boost on</span>
<span class="value">
{Math.ceil(boost!.unitsLeft)}
{production.unitName} left
<Button onclick={() => onboost(undefined)} title="Take back a boost started by mistake"
>Undo</Button
>
</span>
{:else if now >= nextBoost}
<span class="label">Boost ready</span>
<Button onclick={() => onboost(startBoost(production.boost!, now))}>Boost started</Button>
{/if}
</div>
{#if !boosting && now < nextBoost}
<Countdown label="Next boost in" remainingMs={nextBoost - now} />
{/if}
{/if}

{#if ahead}
Expand Down
18 changes: 17 additions & 1 deletion src/components/Businesses.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
import type { Production } from '../data/production'
import type { Property } from '../data/properties'
import { applyOrder, cardAt, moveId, saveOrder, swapAt, type CardBox } from '../lib/order'
import { emptyBars, matchingRate, rateFor, type BusinessBarsById } from '../lib/production'
import {
emptyBars,
matchingRate,
rateFor,
valueBonus,
type Boost,
type BoostsById,
type BusinessBarsById,
} from '../lib/production'
import { ownedProperties, upgradeValue, type OwnedProperties } from '../lib/properties'
import type { GoodsStock } from '../lib/nightclub'
import type { Bars } from '../lib/resync'
Expand All @@ -33,6 +41,9 @@
/** Wall-clock time, for supply deliveries. */
now?: number
onresync?: (propertyId: string, bars: Bars) => void
/** The last production boost by property id (issue #9). */
boosts?: BoostsById
onboost?: (propertyId: string, boost: Boost | undefined) => void
/** Saved card order: property ids (issue #30). */
order?: readonly string[]
onorder?: (order: string[]) => void
Expand All @@ -50,6 +61,8 @@
businesses = {},
now = 0,
onresync = () => {},
boosts = {},
onboost = () => {},
order = [],
onorder = () => {},
goods = [],
Expand Down Expand Up @@ -241,6 +254,9 @@
{now}
editing={editing === property.id}
onresync={(bars) => onresync(property.id, bars)}
bonus={valueBonus(production, owned, property)}
boost={boosts[property.id]}
onboost={(boost) => onboost(property.id, boost)}
/>
{/if}
{#if warehouse}
Expand Down
11 changes: 7 additions & 4 deletions src/components/ui/BusinessBars.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,21 @@
now: number
/** Called with the corrected bars; the tracker stores them as of now (decision 003). */
onresync: (bars: Bars) => void
/** False for a business that cannot be raided: no "Raided" button. */
raids?: boolean
}

let { bars: stored, now, onresync }: Props = $props()
let { bars: stored, now, onresync, raids = true }: Props = $props()

// The bars as of now, with an arrived delivery filled in. Every change starts from these.
const bars = $derived(settleBars(stored, now, SUPPLY_PURCHASE.fillsTo))
const dueAt = $derived(bars.suppliesDueAt)

const events = $derived(
Object.values(BUSINESS_EVENTS).filter(
(event) => bars.supplies !== undefined || !event.empties.includes('supplies'),
),
Object.entries(BUSINESS_EVENTS)
.filter(([id]) => raids || id !== 'raided')
.map(([, event]) => event)
.filter((event) => bars.supplies !== undefined || !event.empties.includes('supplies')),
)

// One undo for the whole business: the last edit, Full or event, whichever bars it changed.
Expand Down
3 changes: 1 addition & 2 deletions src/components/ui/Kit.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@
propertyId: 'sample-a',
unitName: 'crates',
maxUnits: 10,
farSaleBonus: 0.5,
farSaleLabel: 'far away',
farSale: { bonus: 0.5, label: 'far away' },
rates: [
{ upgrades: [], minutesPerUnit: 30, unitValue: 10_000, unitsPerFullSupplies: 4 },
{ upgrades: ['equipment'], minutesPerUnit: 20, unitValue: 12_000 },
Expand Down
76 changes: 68 additions & 8 deletions src/data/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface ProductionRate {
unitValue: number
/** Units one full supply bar makes. Missing while neither a source nor an estimate gives it. */
unitsPerFullSupplies?: number
/** Play time per unit while a production boost runs, for businesses that have one. */
boostedMinutesPerUnit?: number
/** Set when some values are estimates rather than sourced (decision 013). */
estimate?: Estimate
}
Expand All @@ -32,16 +34,34 @@ export interface Production {
unitName: string
/** Units a full stock bar holds. */
maxUnits: number
/** A far sale pays this much more, e.g. 0.5 for +50%. */
farSaleBonus: number
/** Where the far sale goes, e.g. "Los Santos". */
farSaleLabel: string
/** A far sale pays this much more, e.g. 0.5 for +50%; left out if sales pay the same anywhere. */
farSale?: { bonus: number; label: string }
/** A bought upgrade that raises the sale value, e.g. `{ upgrade: 'named', bonus: 0.05 }`. */
valueBonus?: { upgrade: string; bonus: number }
/** False if the business cannot be raided. */
raids?: false
/** A production boost the player can start in the business. */
boost?: ProductionBoost
rates: readonly ProductionRate[]
source: string
checked: string
}

/**
* A boost that runs production at `boostedMinutesPerUnit` until it has made `units` or
* `maxRealMs` of real time has passed since it started. It can be started once a day.
*/
export interface ProductionBoost {
units: number
maxRealMs: number
/** Hour of the day (UTC) when the next boost becomes available. */
resetUtcHour: number
source: string
checked: string
}

const CHECKED = '2026-10-14'
const ACID_CHECKED = '2026-10-15'

const MC_SOURCE =
'gta.wiki, The Open Road: Profits (unit, value and minutes per upgrade level, stock capacity), ' +
Expand Down Expand Up @@ -77,8 +97,7 @@ function mc(
propertyId,
unitName,
maxUnits,
farSaleBonus: 0.5,
farSaleLabel: 'across the county line',
farSale: { bonus: 0.5, label: 'across the county line' },
rates: [
{
upgrades: [],
Expand Down Expand Up @@ -147,8 +166,7 @@ export const PRODUCTION: readonly Production[] = [
propertyId: 'bunker',
unitName: 'units',
maxUnits: 100,
farSaleBonus: 0.5,
farSaleLabel: 'in Los Santos',
farSale: { bonus: 0.5, label: 'in Los Santos' },
// Staff on manufacturing only; research and the split come with issue #33.
rates: [
{ upgrades: [], minutesPerUnit: 10, unitValue: 5_000, unitsPerFullSupplies: 10 },
Expand All @@ -172,4 +190,46 @@ export const PRODUCTION: readonly Production[] = [
'Focused on Production (stock from a full supply bar).',
checked: CHECKED,
},
// Acid Lab (issue #9). Only the equipment upgrade changes production: there is no staff or
// security upgrade, and the lab cannot be raided. Sales pay the same anywhere.
{
propertyId: 'acid-lab',
unitName: 'units',
maxUnits: 160,
valueBonus: { upgrade: 'named', bonus: 0.05 },
raids: false,
boost: {
units: 80,
maxRealMs: 24 * 60 * 60_000,
resetUtcHour: 6,
source:
'gta.wiki, Acid Product: once per real-time day, resetting at 6 a.m. GMT; "lasts until 80 ' +
'units of stock have been produced, or 24 real-time hours have passed".',
checked: ACID_CHECKED,
},
rates: [
{
upgrades: [],
minutesPerUnit: 135 / 60,
boostedMinutesPerUnit: 68 / 60,
unitValue: 1_485,
unitsPerFullSupplies: 50,
},
{
upgrades: ['equipment'],
minutesPerUnit: 90 / 60,
boostedMinutesPerUnit: 45 / 60,
unitValue: 2_095,
unitsPerFullSupplies: 100,
},
],
source:
'gta.wiki, Acid Product: 160 units; seconds and value per unit with and without the ' +
'equipment upgrade, boosted and not; 2 units of supplies per unit without the upgrade, of ' +
'100 in a full bar; a unique name adds 5% to the value. gta.wiki, Acid Lab: no security ' +
'upgrade, as it cannot be raided; a full resupply costs $60,000. GTABoom, Acid Lab Business ' +
'Guide: filling the lab takes $192,000 of supplies without the upgrade and $96,000 with ' +
'it, so 1.6 full bars, or 100 units per full bar, with the upgrade.',
checked: ACID_CHECKED,
},
]
14 changes: 14 additions & 0 deletions src/data/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ export const PROPERTIES: readonly Property[] = [
source: BUNKER_SOURCE,
checked: '2026-10-14',
},
// Equipment is the only production upgrade; there is no security upgrade (no raids). A unique
// product name adds to the sale value (issue #9).
{
id: 'acid-lab',
label: 'Acid Lab',
upgrades: [
{ kind: 'toggle', id: 'equipment', label: 'Equipment' },
{ kind: 'toggle', id: 'named', label: 'Product named' },
],
source:
'gta.wiki, Acid Lab (Upgrades): the equipment upgrade, and no security upgrade. gta.wiki, ' +
'Acid Product: a unique name adds 5% to the sale value.',
checked: '2026-10-15',
},
// Only owned or not: owning either unlocks Cargo and Shipments in the nightclub (issue #7).
{
id: 'special-cargo',
Expand Down
Loading
Loading