From 0d1df7b1c92b6914b751e07d6a5c72c3ab77754d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:37:44 +0000 Subject: [PATCH] Acid Lab: supplies, stock and value with the production boost Sourced rates without and with the equipment upgrade, the 5% named-product bonus, no raids, and the daily production boost (80 units or 24 real hours). Saved state version 8 adds boosts. Decision 017. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/017-production-boost.md | 28 ++++ docs/decisions/README.md | 1 + src/App.svelte | 5 + src/components/BusinessProduction.svelte | 79 ++++++++-- src/components/Businesses.svelte | 18 ++- src/components/ui/BusinessBars.svelte | 11 +- src/components/ui/Kit.svelte | 3 +- src/data/production.ts | 76 +++++++++- src/data/properties.ts | 14 ++ src/lib/production.svelte.ts | 5 +- src/lib/production.test.ts | 126 +++++++++++++++- src/lib/production.ts | 180 ++++++++++++++++++++--- src/lib/state.test.ts | 39 ++++- src/lib/state.ts | 17 ++- 15 files changed, 543 insertions(+), 60 deletions(-) create mode 100644 docs/decisions/017-production-boost.md 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, } }