diff --git a/CLAUDE.md b/CLAUDE.md
index b54ba40..1a8a269 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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/` |
diff --git a/docs/decisions/017-production-boost.md b/docs/decisions/017-production-boost.md
new file mode 100644
index 0000000..acbf2e4
--- /dev/null
+++ b/docs/decisions/017-production-boost.md
@@ -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.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index d6d9174..a85f6e7 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -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
diff --git a/src/App.svelte b/src/App.svelte
index 065df61..1f11a22 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -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}
diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte
index 36b2cc7..671befd 100644
--- a/src/components/BusinessProduction.svelte
+++ b/src/components/BusinessProduction.svelte
@@ -1,17 +1,23 @@
{#if editing}
-
+
{:else}
{est('Value', estimated.value || estimated.production)}
-
-
- {formatValue(value.far, 'money')} far
- · {formatValue(value.nearby, 'money')} nearby
-
+ {#if value.far !== null}
+
+
+ {formatValue(value.far, 'money')} far
+ · {formatValue(value.nearby, 'money')} nearby
+
+ {:else}
+ {formatValue(value.nearby, 'money')}
+ {/if}
+
+{/if}
+
+{#if production.boost && running?.boostedMinutesPerUnit !== undefined}
+
+
+ {#if boosting}
+ Boost on
+
+ {Math.ceil(boost!.unitsLeft)}
+ {production.unitName} left
+
+
+ {:else if now >= nextBoost}
+ Boost ready
+
+ {/if}
+ {#if !boosting && now < nextBoost}
+
+ {/if}
{/if}
{#if ahead}
diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte
index fb9437c..3c192d6 100644
--- a/src/components/Businesses.svelte
+++ b/src/components/Businesses.svelte
@@ -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'
@@ -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
@@ -50,6 +61,8 @@
businesses = {},
now = 0,
onresync = () => {},
+ boosts = {},
+ onboost = () => {},
order = [],
onorder = () => {},
goods = [],
@@ -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}
diff --git a/src/components/ui/BusinessBars.svelte b/src/components/ui/BusinessBars.svelte
index cbbf3f6..6447929 100644
--- a/src/components/ui/BusinessBars.svelte
+++ b/src/components/ui/BusinessBars.svelte
@@ -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.
diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte
index 3733a2d..842eded 100644
--- a/src/components/ui/Kit.svelte
+++ b/src/components/ui/Kit.svelte
@@ -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 },
diff --git a/src/data/production.ts b/src/data/production.ts
index 01bd181..6016ff7 100644
--- a/src/data/production.ts
+++ b/src/data/production.ts
@@ -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
}
@@ -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), ' +
@@ -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: [],
@@ -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 },
@@ -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,
+ },
]
diff --git a/src/data/properties.ts b/src/data/properties.ts
index e7056d1..882558b 100644
--- a/src/data/properties.ts
+++ b/src/data/properties.ts
@@ -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',
diff --git a/src/lib/production.svelte.ts b/src/lib/production.svelte.ts
index c8f80f2..0d702d3 100644
--- a/src/lib/production.svelte.ts
+++ b/src/lib/production.svelte.ts
@@ -10,7 +10,7 @@ import { produceAll } from './production'
import { store } from './store.svelte'
onPlayTime((ms) => {
- store.state.businesses = produceAll(
+ const produced = produceAll(
store.state.businesses,
PROPERTIES,
PRODUCTION,
@@ -18,7 +18,10 @@ onPlayTime((ms) => {
ms,
Date.now(),
SUPPLY_PURCHASE.fillsTo,
+ store.state.boosts,
)
+ store.state.businesses = produced.businesses
+ store.state.boosts = produced.boosts
store.state.nightclub = produceGoods(
store.state.nightclub,
GOODS,
diff --git a/src/lib/production.test.ts b/src/lib/production.test.ts
index 13a9013..658b43d 100644
--- a/src/lib/production.test.ts
+++ b/src/lib/production.test.ts
@@ -7,10 +7,15 @@ import {
matchingRate,
parseBusinessBars,
produce,
+ boostRunning,
+ nextBoostAt,
+ parseBoosts,
produceAll,
rateFor,
stockUnits,
stockValue,
+ valueBonus,
+ type Boost,
type KnownRate,
} from './production'
import type { OwnedProperties } from './properties'
@@ -20,6 +25,7 @@ const MIN = 60_000
const bunker = PRODUCTION.find((p) => p.propertyId === 'bunker')!
const cocaine = PRODUCTION.find((p) => p.propertyId === 'mc-cocaine')!
+const acid = PRODUCTION.find((p) => p.propertyId === 'acid-lab')!
const property = (id: string) => PROPERTIES.find((p) => p.id === id)!
function owning(id: string, upgrades: Record = {}): OwnedProperties {
@@ -194,17 +200,18 @@ describe('produceAll', () => {
bunker: { stock: 0, supplies: 100 },
'mc-cocaine': { stock: 0, supplies: 100 },
}
- const next = produceAll(businesses, PROPERTIES, PRODUCTION, owned, 7 * MIN, 0, 100)
+ const next = produceAll(businesses, PROPERTIES, PRODUCTION, owned, 7 * MIN, 0, 100).businesses
expect(next).toEqual({ ...businesses, bunker: { stock: 1, supplies: 95 } })
})
it('returns the same object when nothing changed', () => {
const businesses = { bunker: { stock: 50, supplies: 0 } }
- expect(produceAll(businesses, PROPERTIES, PRODUCTION, owned, 7 * MIN, 0, 100)).toBe(businesses)
+ const next = produceAll(businesses, PROPERTIES, PRODUCTION, owned, 7 * MIN, 0, 100)
+ expect(next.businesses).toBe(businesses)
})
it('starts a business without saved bars empty', () => {
- expect(produceAll({}, PROPERTIES, PRODUCTION, owned, 7 * MIN, 0, 100)).toEqual({
+ expect(produceAll({}, PROPERTIES, PRODUCTION, owned, 7 * MIN, 0, 100).businesses).toEqual({
bunker: { stock: 0, supplies: 0 },
})
})
@@ -213,13 +220,122 @@ describe('produceAll', () => {
const businesses = { bunker: { stock: 0, supplies: 0, suppliesDueAt: 1000 * MIN } }
// 14 minutes of play ending 7 minutes after the delivery: only the last 7 produce.
const next = produceAll(businesses, PROPERTIES, PRODUCTION, owned, 14 * MIN, 1007 * MIN, 100)
- expect(next.bunker).toEqual({ stock: 1, supplies: 95 })
+ expect(next.businesses.bunker).toEqual({ stock: 1, supplies: 95 })
})
it('leaves a delivery still on its way', () => {
const businesses = { bunker: { stock: 0, supplies: 10, suppliesDueAt: 1000 * MIN } }
const next = produceAll(businesses, PROPERTIES, PRODUCTION, owned, 7 * MIN, 999 * MIN, 100)
- expect(next.bunker).toEqual({ stock: 1, supplies: 5, suppliesDueAt: 1000 * MIN })
+ expect(next.businesses.bunker).toEqual({ stock: 1, supplies: 5, suppliesDueAt: 1000 * MIN })
+ })
+})
+
+describe('acid lab', () => {
+ const lab = property('acid-lab')
+ const upgraded = owning('acid-lab', { equipment: true })
+ // With equipment: 1.5 minutes per unit, 45 s boosted; 160 units (0.625% each), 100 per bar.
+ const rate = rateFor(acid, upgraded, lab)!
+ const run = (bars: Bars, boost: Boost | undefined, playMs: number, now: number) =>
+ produceAll({ 'acid-lab': bars }, PROPERTIES, PRODUCTION, upgraded, playMs, now, 100, {
+ ...(boost && { 'acid-lab': boost }),
+ })
+
+ it('has a rate with and without equipment, named or not', () => {
+ expect(rateFor(acid, owning('acid-lab', { named: true }), lab)?.unitValue).toBe(1_485)
+ expect(rate).toMatchObject({ unitValue: 2_095, unitsPerFullSupplies: 100 })
+ })
+
+ it('matches the full values on the wiki, named and not', () => {
+ const full = { stock: 100 }
+ expect(stockValue(full, acid, rate)).toEqual({ nearby: 335_200, far: null })
+ const named = owning('acid-lab', { equipment: true, named: true })
+ expect(valueBonus(acid, named, lab)).toBe(0.05)
+ expect(stockValue(full, acid, rate, valueBonus(acid, named, lab)).nearby).toBe(351_840)
+ const plain = rateFor(acid, owning('acid-lab'), lab)!
+ expect(stockValue(full, acid, plain, 0.05).nearby).toBe(249_440)
+ expect(valueBonus(acid, upgraded, lab)).toBe(0)
+ })
+
+ it('fills in the times the wiki gives, without and with the upgrade', () => {
+ const plain = rateFor(acid, owning('acid-lab'), lab)!
+ // Supplies for 50 units, 100 with equipment; the wiki times assume enough supplies.
+ expect(plain.minutesPerUnit * 160).toBe(6 * 60)
+ expect(rate.minutesPerUnit * 160).toBe(4 * 60)
+ })
+
+ it('makes the boosted units at the boosted speed, then the rest at the normal one', () => {
+ const boost = { startedAt: 0, unitsLeft: 80 }
+ // 80 units in 60 minutes boosted, then 20 in 30 minutes until supplies run out.
+ const next = run({ stock: 0, supplies: 100 }, boost, 90 * MIN, 90 * MIN)
+ expect(next.businesses['acid-lab']).toEqual({ stock: 62.5, supplies: 0 })
+ expect(next.boosts['acid-lab']).toEqual({ startedAt: 0, unitsLeft: 0 })
+ })
+
+ it('ends the boost after 24 real hours', () => {
+ const boost = { startedAt: 0, unitsLeft: 80 }
+ // 30 minutes boosted (40 units), then 30 minutes not (20 units).
+ const next = run({ stock: 0, supplies: 100 }, boost, 60 * MIN, 24 * 60 * MIN + 30 * MIN)
+ expect(next.businesses['acid-lab']?.stock).toBeCloseTo(37.5, 9)
+ expect(next.boosts['acid-lab']?.unitsLeft).toBeCloseTo(40, 9)
+ })
+
+ it('starts the boost when it was started', () => {
+ const boost = { startedAt: 30 * MIN, unitsLeft: 80 }
+ // 30 minutes not boosted (20 units), then 30 minutes boosted (40 units).
+ const next = run({ stock: 0, supplies: 100 }, boost, 60 * MIN, 60 * MIN)
+ expect(next.businesses['acid-lab']?.stock).toBeCloseTo(37.5, 9)
+ expect(next.boosts['acid-lab']?.unitsLeft).toBeCloseTo(40, 9)
+ })
+
+ it('keeps the boost while nothing is made', () => {
+ const boosts = { 'acid-lab': { startedAt: 0, unitsLeft: 80 } }
+ const businesses = { 'acid-lab': { stock: 10, supplies: 0 } }
+ const next = produceAll(businesses, PROPERTIES, PRODUCTION, upgraded, MIN, MIN, 100, boosts)
+ expect(next).toEqual({ businesses, boosts })
+ expect(next.boosts).toBe(boosts)
+ })
+
+ it('forecasts with the boost first', () => {
+ expect(forecast({ stock: 0, supplies: 100 }, acid, rate, 80)).toEqual({
+ fullInMs: null,
+ suppliesOutInMs: 90 * MIN,
+ })
+ expect(forecast({ stock: 50, supplies: 100 }, acid, rate, 80).fullInMs).toBe(60 * MIN)
+ expect(forecast({ stock: 50, supplies: 100 }, acid, rate).fullInMs).toBe(120 * MIN)
+ })
+
+ it('runs a boost until its units are made or 24 real hours pass', () => {
+ const boost = { startedAt: 1_000, unitsLeft: 5 }
+ expect(boostRunning(boost, acid.boost, 999)).toBe(false)
+ expect(boostRunning(boost, acid.boost, 1_000)).toBe(true)
+ expect(boostRunning(boost, acid.boost, 1_000 + 24 * 60 * MIN)).toBe(false)
+ expect(boostRunning({ ...boost, unitsLeft: 0 }, acid.boost, 2_000)).toBe(false)
+ })
+
+ it('allows the next boost after the next 6:00 UTC', () => {
+ const at = (iso: string) => ({ startedAt: Date.parse(iso), unitsLeft: 80 })
+ expect(nextBoostAt(undefined, acid.boost!)).toBe(-Infinity)
+ expect(nextBoostAt(at('2026-10-15T05:59:00Z'), acid.boost!)).toBe(
+ Date.parse('2026-10-15T06:00:00Z'),
+ )
+ expect(nextBoostAt(at('2026-10-15T06:00:00Z'), acid.boost!)).toBe(
+ Date.parse('2026-10-16T06:00:00Z'),
+ )
+ })
+})
+
+describe('parseBoosts', () => {
+ it('reads boosts', () => {
+ const data = { 'acid-lab': { startedAt: 5, unitsLeft: 0 } }
+ expect(parseBoosts(data)).toEqual(data)
+ })
+
+ it.each([
+ ['not an object', [], 'missing'],
+ ['a missing start', { a: { unitsLeft: 5 } }, 'business "a"'],
+ ['units below zero', { a: { startedAt: 0, unitsLeft: -1 } }, 'business "a"'],
+ ])('rejects %s', (_, data, message) => {
+ expect(parseBoosts(data)).toContain(message)
})
})
diff --git a/src/lib/production.ts b/src/lib/production.ts
index 89789a0..1dccd79 100644
--- a/src/lib/production.ts
+++ b/src/lib/production.ts
@@ -1,6 +1,6 @@
// Business production: stock rises on play time while supplies last (issue #6, decision 012).
// Pure logic: play time and wall-clock time come in as arguments (decision 003).
-import type { Production, ProductionRate } from '../data/production'
+import type { Production, ProductionBoost, ProductionRate } from '../data/production'
import type { Property } from '../data/properties'
import { isOwned, upgradeValue, type OwnedProperties } from './properties'
import { settleBars, type Bars } from './resync'
@@ -105,7 +105,31 @@ export interface Forecast {
suppliesOutInMs: number | null
}
-export function forecast(bars: Bars, production: Production, rate: KnownRate): Forecast {
+/**
+ * What comes first, in play time. With `boostUnitsLeft`, that many units are made at the
+ * boosted speed first (the rate must have one).
+ */
+export function forecast(
+ bars: Bars,
+ production: Production,
+ rate: KnownRate,
+ boostUnitsLeft = 0,
+): Forecast {
+ if (boostUnitsLeft <= 0 || rate.boostedMinutesPerUnit === undefined) {
+ return plainForecast(bars, production, rate)
+ }
+ const fast: KnownRate = { ...rate, minutesPerUnit: rate.boostedMinutesPerUnit }
+ const boostMs = boostUnitsLeft * fast.minutesPerUnit * MINUTE_MS
+ const boosted = plainForecast(bars, production, fast)
+ if ((boosted.fullInMs ?? boosted.suppliesOutInMs ?? 0) <= boostMs) return boosted
+ const rest = plainForecast(produce(bars, production, fast, boostMs), production, rate)
+ return {
+ fullInMs: rest.fullInMs === null ? null : rest.fullInMs + boostMs,
+ suppliesOutInMs: rest.suppliesOutInMs === null ? null : rest.suppliesOutInMs + boostMs,
+ }
+}
+
+function plainForecast(bars: Bars, production: Production, rate: KnownRate): Forecast {
const { untilFull, untilOut } = timeLeft(bars, production, rate)
if (untilFull <= 0) return { fullInMs: 0, suppliesOutInMs: null }
return {
@@ -120,21 +144,110 @@ export function stockUnits(bars: Bars, production: Production): number {
return Math.floor((bars.stock * production.maxUnits) / 100 + 1e-6)
}
-/** Sale value of the stock, nearby and far. */
+/** What bought upgrades add to the sale value, e.g. 0.05 for a named product. */
+export function valueBonus(
+ production: Production,
+ owned: OwnedProperties,
+ property: Property,
+): number {
+ const bonus = production.valueBonus
+ if (!bonus) return 0
+ const upgrade = property.upgrades.find((u) => u.id === bonus.upgrade)
+ return upgrade && upgradeValue(owned, property.id, upgrade) === true ? bonus.bonus : 0
+}
+
+/** Sale value of the stock nearby, and far if a far sale pays more (null if not). */
export function stockValue(
bars: Bars,
production: Production,
rate: ProductionRate,
-): { nearby: number; far: number } {
- const nearby = stockUnits(bars, production) * rate.unitValue
- return { nearby, far: nearby * (1 + production.farSaleBonus) }
+ bonus = 0,
+): { nearby: number; far: number | null } {
+ // The game pays whole dollars per unit: 5% on $2,095 is $2,199 (gta.wiki, Acid Product).
+ const nearby = stockUnits(bars, production) * Math.floor(rate.unitValue * (1 + bonus))
+ const far = production.farSale ? nearby * (1 + production.farSale.bonus) : null
+ return { nearby, far }
+}
+
+/** A production boost the user started: when (wall clock) and the units it still speeds up. */
+export interface Boost {
+ startedAt: number
+ unitsLeft: number
+}
+
+/** The last boost of each business by property id, kept after it ends for the daily limit. */
+export type BoostsById = Record
+
+const DAY_MS = 24 * 60 * MINUTE_MS
+
+/** A boost started at `now`. */
+export function startBoost(boost: ProductionBoost, now: number): Boost {
+ return { startedAt: now, unitsLeft: boost.units }
+}
+
+/** Whether the boost speeds up production at wall-clock time `now`. */
+export function boostRunning(
+ boost: Boost | undefined,
+ data: ProductionBoost | undefined,
+ now: number,
+): boolean {
+ return (
+ !!boost &&
+ !!data &&
+ boost.unitsLeft > 0 &&
+ now >= boost.startedAt &&
+ now < boost.startedAt + data.maxRealMs
+ )
+}
+
+/** When the next boost can be started (wall clock): the first daily reset after the last one. */
+export function nextBoostAt(boost: Boost | undefined, data: ProductionBoost): number {
+ if (!boost) return -Infinity
+ const day = new Date(boost.startedAt)
+ const reset = Date.UTC(
+ day.getUTCFullYear(),
+ day.getUTCMonth(),
+ day.getUTCDate(),
+ data.resetUtcHour,
+ )
+ return reset > boost.startedAt ? reset : reset + DAY_MS
}
/**
- * Counts a stretch of play time for every owned business with a known rate. Supplies bought
- * that arrived during the stretch count from their arrival: the stretch is taken to end at
- * `now` (wall clock), which is exact for normal ticks. Returns `businesses` itself if nothing
- * changed, so an idle tick does not trigger a save.
+ * `ms` of production starting at wall-clock time `at`, boosted while the boost runs. The boost
+ * counts the units it made.
+ */
+function step(
+ bars: Bars,
+ production: Production,
+ rate: KnownRate,
+ boost: Boost | undefined,
+ ms: number,
+ at: number,
+): { bars: Bars; boost: Boost | undefined } {
+ const boosted = rate.boostedMinutesPerUnit
+ if (!boost || boosted === undefined || !boostRunning(boost, production.boost, at)) {
+ return { bars: produce(bars, production, rate, ms), boost }
+ }
+ const fast: KnownRate = { ...rate, minutesPerUnit: boosted }
+ const boostMs = Math.min(ms, boost.unitsLeft * boosted * MINUTE_MS)
+ const after = produce(bars, production, fast, boostMs)
+ if (after === bars) return { bars, boost }
+ const made = ((after.stock - bars.stock) * production.maxUnits) / 100
+ // The small margin ends the boost despite floating-point leftovers.
+ const unitsLeft = boost.unitsLeft - made < 1e-6 ? 0 : boost.unitsLeft - made
+ return {
+ bars: produce(after, production, rate, ms - boostMs),
+ boost: { ...boost, unitsLeft },
+ }
+}
+
+/**
+ * Counts a stretch of play time for every owned business with a known rate. The stretch is
+ * taken to end at `now` (wall clock), which is exact for normal ticks: supplies bought that
+ * arrived during it count from their arrival, and a boost runs from its start to its end.
+ * Returns `businesses` and `boosts` themselves if nothing changed, so an idle tick does not
+ * trigger a save.
*/
export function produceAll(
businesses: BusinessBarsById,
@@ -144,20 +257,32 @@ export function produceAll(
playMs: number,
now: number,
fillsTo: number,
-): BusinessBarsById {
- let next = businesses
+ boosts: BoostsById = {},
+): { businesses: BusinessBarsById; boosts: BoostsById } {
+ let next = { businesses, boosts }
for (const production of productions) {
const property = catalog.find((p) => p.id === production.propertyId)
if (!property || !isOwned(owned, property.id)) continue
const rate = rateFor(production, owned, property)
if (!rate) continue
- let bars = businesses[property.id] ?? emptyBars()
- const due = bars.suppliesDueAt
- const afterDelivery = due === undefined ? 0 : Math.min(playMs, Math.max(0, now - due))
- bars = produce(bars, production, rate, playMs - afterDelivery)
+ const id = property.id
+ let bars = businesses[id] ?? emptyBars()
+ let boost = boosts[id]
+ // Where something changes during the stretch, in wall-clock time.
+ const start = now - playMs
+ const ends = boost && production.boost ? boost.startedAt + production.boost.maxRealMs : 0
+ const cuts = [bars.suppliesDueAt, boost?.startedAt, boost && ends]
+ .filter((t): t is number => t !== undefined && t > start && t < now)
+ .sort((a, b) => a - b)
+ let at = start
+ for (const cut of [...cuts, now]) {
+ bars = settleBars(bars, at, fillsTo)
+ ;({ bars, boost } = step(bars, production, rate, boost, cut - at, at))
+ at = cut
+ }
bars = settleBars(bars, now, fillsTo)
- bars = produce(bars, production, rate, afterDelivery)
- if (bars !== businesses[property.id]) next = { ...next, [property.id]: bars }
+ if (bars !== businesses[id]) next = { ...next, businesses: { ...next.businesses, [id]: bars } }
+ if (boost !== boosts[id] && boost) next = { ...next, boosts: { ...next.boosts, [id]: boost } }
}
return next
}
@@ -195,3 +320,22 @@ function isPercent(value: unknown): value is number {
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+
+/** Checks saved boosts. Returns an error message if the shape is wrong. */
+export function parseBoosts(data: unknown): BoostsById | string {
+ if (!isRecord(data)) return 'The boosts section is missing.'
+ const result: BoostsById = {}
+ for (const [id, entry] of Object.entries(data)) {
+ if (
+ !isRecord(entry) ||
+ typeof entry.startedAt !== 'number' ||
+ !Number.isFinite(entry.startedAt) ||
+ typeof entry.unitsLeft !== 'number' ||
+ !(entry.unitsLeft >= 0)
+ ) {
+ return `The saved boost of business "${id}" is not readable.`
+ }
+ result[id] = { startedAt: entry.startedAt, unitsLeft: entry.unitsLeft }
+ }
+ return result
+}
diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts
index 5b5951f..d432603 100644
--- a/src/lib/state.test.ts
+++ b/src/lib/state.test.ts
@@ -25,13 +25,14 @@ describe('parseState', () => {
expect(result).toEqual({
ok: true,
state: {
- version: 7,
+ version: 8,
clock: { trackOnOpen: false },
properties: {},
businesses: {},
businessOrder: [],
nightclub: {},
safes: {},
+ boosts: {},
},
fromVersion: 1,
})
@@ -42,13 +43,14 @@ describe('parseState', () => {
expect(result).toEqual({
ok: true,
state: {
- version: 7,
+ version: 8,
clock: { trackOnOpen: true },
properties: {},
businesses: {},
businessOrder: [],
nightclub: {},
safes: {},
+ boosts: {},
},
fromVersion: 2,
})
@@ -62,13 +64,14 @@ describe('parseState', () => {
expect(result).toEqual({
ok: true,
state: {
- version: 7,
+ version: 8,
clock: { trackOnOpen: false },
properties,
businesses: {},
businessOrder: [],
nightclub: {},
safes: {},
+ boosts: {},
},
fromVersion: 3,
})
@@ -83,13 +86,14 @@ describe('parseState', () => {
expect(result).toEqual({
ok: true,
state: {
- version: 7,
+ version: 8,
clock: { trackOnOpen: true },
properties,
businesses,
businessOrder: [],
nightclub: {},
safes: {},
+ boosts: {},
},
fromVersion: 4,
})
@@ -106,7 +110,7 @@ describe('parseState', () => {
}
expect(parseState(JSON.stringify(sample))).toEqual({
ok: true,
- state: { ...sample, version: 7, nightclub: {}, safes: {} },
+ state: { ...sample, version: 8, nightclub: {}, safes: {}, boosts: {} },
fromVersion: 5,
})
})
@@ -122,11 +126,34 @@ describe('parseState', () => {
}
expect(parseState(JSON.stringify(sample))).toEqual({
ok: true,
- state: { ...sample, version: 7, safes: {} },
+ state: { ...sample, version: 8, safes: {}, boosts: {} },
fromVersion: 6,
})
})
+ it('migrates a version 7 sample, with no boosts', () => {
+ const sample = {
+ version: 7,
+ clock: { trackOnOpen: false },
+ properties: { 'acid-lab': { owned: true, upgrades: {} } },
+ businesses: { 'acid-lab': { stock: 10, supplies: 50 } },
+ businessOrder: [],
+ nightclub: {},
+ safes: {},
+ }
+ expect(parseState(JSON.stringify(sample))).toEqual({
+ ok: true,
+ state: { ...sample, version: 8, boosts: {} },
+ fromVersion: 7,
+ })
+ })
+
+ it('keeps saved boosts', () => {
+ const boosts = { 'acid-lab': { startedAt: 1_000, unitsLeft: 42.5 } }
+ const text = JSON.stringify({ ...defaultState(), boosts })
+ expect(parseState(text)).toMatchObject({ ok: true, state: { boosts } })
+ })
+
it('keeps saved safes', () => {
const safes = { nightclub: { money: 50_000, sinceMs: 600_000, popularity: 85 } }
const text = JSON.stringify({ ...defaultState(), safes })
diff --git a/src/lib/state.ts b/src/lib/state.ts
index 304938f..2d58446 100644
--- a/src/lib/state.ts
+++ b/src/lib/state.ts
@@ -2,12 +2,17 @@
import { defaultClockSettings, type ClockSettings } from './clock'
import { parseGoodsStock, type GoodsStock } from './nightclub'
import { parseOrder } from './order'
-import { parseBusinessBars, type BusinessBarsById } from './production'
+import {
+ parseBoosts,
+ parseBusinessBars,
+ type BoostsById,
+ 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 = 7
+export const CURRENT_VERSION = 8
/** All user state. Trackers add their own sections here as they are built. */
export interface AppState {
@@ -24,6 +29,8 @@ export interface AppState {
nightclub: GoodsStock
/** Money in each safe and nightclub popularity, as of the last play time counted (issue #8). */
safes: SafesById
+ /** The last production boost of each business, e.g. the Acid Lab's (issue #9). */
+ boosts: BoostsById
}
export function defaultState(): AppState {
@@ -35,6 +42,7 @@ export function defaultState(): AppState {
businessOrder: [],
nightclub: {},
safes: {},
+ boosts: {},
}
}
@@ -57,6 +65,8 @@ export const MIGRATIONS: Readonly> = {
5: (data) => ({ ...data, nightclub: {} }),
// Version 7 adds property safes (issue #8); empty until set or paid.
6: (data) => ({ ...data, safes: {} }),
+ // Version 8 adds production boosts (issue #9); none started yet.
+ 7: (data) => ({ ...data, boosts: {} }),
}
export type ParseResult =
@@ -137,6 +147,8 @@ function toState(data: StoredData): AppState | string {
if (typeof nightclub === 'string') return nightclub
const safes = parseSafes(data.safes)
if (typeof safes === 'string') return safes
+ const boosts = parseBoosts(data.boosts)
+ if (typeof boosts === 'string') return boosts
return {
version: CURRENT_VERSION,
clock,
@@ -145,6 +157,7 @@ function toState(data: StoredData): AppState | string {
businessOrder,
nightclub,
safes,
+ boosts,
}
}