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 @@ -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) |
Expand Down
27 changes: 27 additions & 0 deletions docs/decisions/025-bunker-staff.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 36 additions & 2 deletions src/data/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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
Expand All @@ -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<Record<string, number>>
/** 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.
Expand Down Expand Up @@ -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 },
Expand Down
18 changes: 16 additions & 2 deletions src/data/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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',
Expand Down
65 changes: 65 additions & 0 deletions src/lib/production.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
36 changes: 32 additions & 4 deletions src/lib/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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,
}
}
Expand Down
Loading