From 0aca6e71e01f31b09609eb00fcdc6eca98a560c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:31:43 +0000 Subject: [PATCH] Bunker staff on Manufacturing, Both or Research (#33) A choice upgrade on the Bunker picks what the staff work on. Both makes stock at half speed (the sourced 20/17/14 minutes per unit), Research none. Supplies are used as fast on every option, marked as an estimate (decision 013). Research progress is not tracked. Decision 025. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/025-bunker-staff.md | 27 +++++++++++++ docs/decisions/README.md | 1 + src/data/production.ts | 38 ++++++++++++++++- src/data/properties.ts | 18 ++++++++- src/lib/production.test.ts | 65 ++++++++++++++++++++++++++++++ src/lib/production.ts | 36 +++++++++++++++-- 7 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 docs/decisions/025-bunker-staff.md diff --git a/CLAUDE.md b/CLAUDE.md index 5fd2f84..7515baa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Money Fronts safe and heat | `data/fronts.ts`, `lib/fronts.ts` (018) | | Value bonus for owning a front | `ownedBonus` in `data/production.ts` (019) | | Shut-down businesses (owned, not running) | `isRunning` in `lib/properties.ts` (020) | +| Bunker staff on manufacturing/both/research | `staffSplit` in `data/production.ts` (025) | | Real-time timers: Lucky Wheel, Mansion boost | `data/timers.ts`, `lib/timers.ts` (022) | | Overview tab, next events, notifications | `lib/events.ts`, `lib/overview.ts` (dec. 023) | | Theme tokens | `src/app.css` (decision 009) | diff --git a/docs/decisions/025-bunker-staff.md b/docs/decisions/025-bunker-staff.md new file mode 100644 index 0000000..d6880d1 --- /dev/null +++ b/docs/decisions/025-bunker-staff.md @@ -0,0 +1,27 @@ +# 025: Bunker staff assignment + +2026-10-15, issue #33. Builds on decisions 011, 012 and 013. + +## Decision + +- **A choice upgrade on the Bunker:** "Staff on" Manufacturing (default), Both or Research + (`staff-assignment` in `src/data/properties.ts`). Saved like any choice, so no migration. +- **A stock share, not separate rates:** `staffSplit` in `src/data/production.ts` gives the share + of the work that makes stock per option: Both 0.5, Research 0. `matchingRate` applies it on top + of the manufacturing rate for the bought upgrades, so the four sourced rates stay the only ones. + Both then makes 1 unit every 20 / 17 / 14 minutes, as gta.wiki lists for the split. +- **Supply use is the same whatever the staff work on (est.):** no source gives it. Research is + taken to use supplies as fast as manufacturing, so supplies last as long on every option, Both + makes half the stock from a bar and Research none. The bars and countdowns say "(est.)" on Both + and Research, and the card explains the rule. +- **Research on its own** never fills the stock: the card counts down to supplies out only. +- **Research progress is not tracked:** no project bar, "done in" or "Project finished" event. + Only its effect on supplies and stock matters here. +- **Not modelled:** whether staff on Both keep researching once the stock is full. Production + stops at full stock, as on Manufacturing. + +## Why + +The supply use is what decides when to resupply and sell. A share on the existing rates covers +both options without inventing rates per upgrade level, and keeps the estimate to one stated +rule. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ffe5714..1790e7e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -35,3 +35,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [022](022-real-time-timers.md) Timers tab on real time: Lucky Wheel (24 h) and Mansion production boost (×3 for 24 h, 24 h cooldown, not the same business twice in a row; with the Acid Lab boost: est.) - [023](023-overview.md) Overview tab: trackers give a shared `NextEvent`, due first then soonest, supplies on their way counted in the forecast, links that scroll to the card, quick actions with undo on every ready item, popularity in view; alerts as pop-ups and/or notifications, set time ahead - [024](024-several-tabs.md) Several tabs: a tab takes the state another tab saves (`storage` event); only the tab that last started counting counts play time (`BroadcastChannel`) +- [025](025-bunker-staff.md) Bunker staff on Manufacturing, Both or Research: a stock share (Both 0.5, Research 0) on the sourced rates, supplies used as fast on every option (est.), research progress not tracked diff --git a/src/data/production.ts b/src/data/production.ts index 57f544f..ee7b0cc 100644 --- a/src/data/production.ts +++ b/src/data/production.ts @@ -19,8 +19,16 @@ export interface ProductionRate { minutesPerUnit: number /** Sale value of one unit sold nearby, in dollars. */ unitValue: number - /** Units one full supply bar makes. Missing while neither a source nor an estimate gives it. */ + /** + * Units one full supply bar makes (with staff on manufacturing only, where staff can be + * split). Missing while neither a source nor an estimate gives it. + */ unitsPerFullSupplies?: number + /** + * Share of the work that makes stock, 1 if left out: the rest goes to research, which uses + * supplies just the same but makes no stock (`StaffSplit`). + */ + stockShare?: 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). */ @@ -44,6 +52,8 @@ export interface Production { raids?: false /** A production boost the player can start in the business. */ boost?: ProductionBoost + /** A choice of what the staff work on, e.g. manufacturing or research. */ + staffSplit?: StaffSplit rates: readonly ProductionRate[] source: string checked: string @@ -60,6 +70,19 @@ export interface OwnedBonus { source: string } +/** + * What the staff work on, picked by a choice upgrade: the stock share per option (options not + * listed make stock only). Supply use per unit of work is the same for every option. + */ +export interface StaffSplit { + /** The choice upgrade in `PROPERTIES` that picks it. */ + upgrade: string + stockShare: Readonly> + /** How the supply use was derived, shown to the user (decision 013). */ + how: string + source: 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. @@ -200,7 +223,18 @@ export const PRODUCTION: readonly Production[] = [ unitName: 'units', maxUnits: 100, farSale: { bonus: 0.5, label: 'in Los Santos' }, - // Staff on manufacturing only; research and the split come with issue #33. + // Rates with staff on manufacturing only; the split halves them (issue #33). + staffSplit: { + upgrade: 'staff-assignment', + stockShare: { both: 0.5, research: 0 }, + how: + 'Research uses supplies as fast as manufacturing, so supplies last as long whatever ' + + 'the staff work on, and Both makes half the stock from them.', + source: + 'gta.wiki, Disruption Logistics: Profits (split between production and research, 1 ' + + 'unit every 20 / 17 / 14 minutes, half the manufacturing speed). gta.wiki, Bunker ' + + 'Research: research makes no stock.', + }, rates: [ { upgrades: [], minutesPerUnit: 10, unitValue: 5_000, unitsPerFullSupplies: 10 }, { upgrades: ['staff'], minutesPerUnit: 8.5, unitValue: 6_000, unitsPerFullSupplies: 10 }, diff --git a/src/data/properties.ts b/src/data/properties.ts index 08e9e21..d9c10d2 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -67,7 +67,8 @@ const PRODUCTION_UPGRADES: readonly Upgrade[] = [ const MC_SOURCE = 'gta.wiki, The Open Road (Upgrades): equipment, staff and security upgrades.' const BUNKER_SOURCE = - 'gta.wiki, Disruption Logistics (Buy Upgrades): equipment, staff and security upgrades.' + 'gta.wiki, Disruption Logistics (Buy Upgrades): equipment, staff and security upgrades, ' + + 'and staff on Manufacturing, Research or an even split to Both.' export const PROPERTIES: readonly Property[] = [ mc('mc-cocaine', 'Cocaine Lockup'), @@ -78,7 +79,20 @@ export const PROPERTIES: readonly Property[] = [ { id: 'bunker', label: 'Bunker', - upgrades: PRODUCTION_UPGRADES, + // What the staff work on changes production (issue #33); research itself is not tracked. + upgrades: [ + ...PRODUCTION_UPGRADES, + { + kind: 'choice', + id: 'staff-assignment', + label: 'Staff on', + options: [ + { id: 'manufacturing', label: 'Manufacturing' }, + { id: 'both', label: 'Both' }, + { id: 'research', label: 'Research' }, + ], + }, + ], canShutDown: true, source: BUNKER_SOURCE, checked: '2026-10-14', diff --git a/src/lib/production.test.ts b/src/lib/production.test.ts index f7d132d..a8bc12d 100644 --- a/src/lib/production.test.ts +++ b/src/lib/production.test.ts @@ -180,6 +180,71 @@ describe('forecast', () => { }) }) +describe('bunker staff assignment (issue #33)', () => { + const staffOn = (assignment: string): KnownRate => + rateFor( + bunker, + { + bunker: { + owned: true, + upgrades: { equipment: true, staff: true, 'staff-assignment': assignment }, + }, + }, + property('bunker'), + )! + + it('keeps the sourced rate on manufacturing, the default', () => { + expect(staffOn('manufacturing')).toEqual(fast) + expect(fast.stockShare).toBeUndefined() + expect(fast.estimate).toBeUndefined() + }) + + it('makes stock at half speed on both, with supplies used as fast (est.)', () => { + const both = staffOn('both') + // 1 unit every 14 minutes with both upgrades (gta.wiki), supplies for 20 x 7 minutes. + expect(produce({ stock: 0, supplies: 100 }, bunker, both, 70 * MIN)).toEqual({ + stock: 5, + supplies: 50, + }) + expect(forecast({ stock: 0, supplies: 100 }, bunker, both)).toEqual({ + fullInMs: null, + suppliesOutInMs: 140 * MIN, + }) + expect(estimatedParts(both)).toEqual({ value: false, production: true }) + }) + + it('makes no stock on research, and supplies still run out (est.)', () => { + const research = staffOn('research') + expect(produce({ stock: 40, supplies: 100 }, bunker, research, 70 * MIN)).toEqual({ + stock: 40, + supplies: 50, + }) + expect(forecast({ stock: 100, supplies: 100 }, bunker, research)).toEqual({ + fullInMs: null, + suppliesOutInMs: 140 * MIN, + }) + expect(produce({ stock: 40, supplies: 0 }, bunker, research, 70 * MIN)).toEqual({ + stock: 40, + supplies: 0, + }) + expect(research.estimate?.fields).toEqual(['unitsPerFullSupplies']) + }) + + it('adds its estimate to one the rate already has', () => { + const rates = bunker.rates.map((r) => + r.upgrades.length === 0 + ? { ...r, estimate: { fields: ['unitValue' as const], how: 'A.' } } + : r, + ) + const owned = { bunker: { owned: true, upgrades: { 'staff-assignment': 'both' } } } + const rate = matchingRate({ ...bunker, rates }, owned, property('bunker')) + expect(rate?.estimate).toEqual({ + fields: ['unitValue', 'unitsPerFullSupplies'], + how: `A. ${bunker.staffSplit!.how}`, + }) + }) +}) + describe('stock value', () => { const full: KnownRate = { upgrades: [], diff --git a/src/lib/production.ts b/src/lib/production.ts index 2bfff51..a5804c2 100644 --- a/src/lib/production.ts +++ b/src/lib/production.ts @@ -22,7 +22,8 @@ export function emptyBars(): Bars { /** * The rate for exactly the upgrades bought, or undefined if the data has none for that - * combination. Upgrades no rate mentions (none yet) are ignored. + * combination, split by what the staff work on (issue #33). Upgrades no rate mentions (none + * yet) are ignored. */ export function matchingRate( production: Production, @@ -34,9 +35,35 @@ export function matchingRate( .filter((upgrade) => relevant.has(upgrade.id)) .filter((upgrade) => upgradeValue(owned, property.id, upgrade) === true) .map((upgrade) => upgrade.id) - return production.rates.find( + const rate = production.rates.find( (r) => r.upgrades.length === bought.length && bought.every((id) => r.upgrades.includes(id)), ) + return rate && withStaffSplit(rate, production, owned, property) +} + +/** `rate` with the stock share the staff choice gives, and its supply use marked estimated. */ +function withStaffSplit( + rate: ProductionRate, + production: Production, + owned: OwnedProperties, + property: Property, +): ProductionRate { + const split = production.staffSplit + const upgrade = split && property.upgrades.find((u) => u.id === split.upgrade) + if (!split || !upgrade) return rate + const share = split.stockShare[String(upgradeValue(owned, property.id, upgrade))] + if (share === undefined || share === 1) return rate + const fields = rate.estimate?.fields ?? [] + return { + ...rate, + stockShare: share, + estimate: { + fields: fields.includes('unitsPerFullSupplies') + ? fields + : [...fields, 'unitsPerFullSupplies'], + how: rate.estimate ? `${rate.estimate.how} ${split.how}` : split.how, + }, + } } /** `matchingRate` if it is complete enough to run production: its supply use is known. */ @@ -68,7 +95,7 @@ export function estimatedParts(rate: ProductionRate | undefined): { function perUnit(production: Production, rate: KnownRate) { return { ms: rate.minutesPerUnit * MINUTE_MS, - stock: 100 / production.maxUnits, + stock: (100 / production.maxUnits) * (rate.stockShare ?? 1), supplies: 100 / rate.unitsPerFullSupplies, } } @@ -78,7 +105,8 @@ function timeLeft(bars: Bars, production: Production, rate: KnownRate) { const unit = perUnit(production, rate) return { unit, - untilFull: ((100 - bars.stock) / unit.stock) * unit.ms, + // Staff on research only never fill the stock. + untilFull: unit.stock === 0 ? Infinity : ((100 - bars.stock) / unit.stock) * unit.ms, untilOut: ((bars.supplies ?? 0) / unit.supplies) * unit.ms, } }