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 @@ -26,6 +26,7 @@ Svelte 5 + TypeScript + Vite. Public repository.
| Safes and nightclub popularity | `data/safes.ts`, `lib/safes.ts` (decision 016) |
| Acid Lab, production boost | `data/production.ts`, `lib/production.ts` (017) |
| Money Fronts safe and heat | `data/fronts.ts`, `lib/fronts.ts` (018) |
| Value bonus for owning a front | `ownedBonus` in `data/production.ts` (019) |
| Theme tokens | `src/app.css` (decision 009) |
| CI, templates | `.github/` |
| Skills: `handoff`, `finish-pr`; agent: `scout` | `.claude/` |
Expand Down
25 changes: 25 additions & 0 deletions docs/decisions/019-owned-value-bonus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 019: Sale value bonus for owning another property

2026-10-15, issue #49. Builds on decisions 012, 013, 017 and 018.

## Decision

- **Optional `ownedBonus`** in `src/data/production.ts`: a property that must be owned, its
label, the bonus and its own source. Used by two Money Fronts: Hands On Car Wash (+35%
Counterfeit Cash) and Smoke on the Water (+35% Weed Farm), both from gta.wiki.
- The bonus joins `valueBonus` and raises the nearby value, rounded down to whole dollars per
unit like the named Acid Lab product (decision 017). Heat does not turn it off: no source says
it does.
- **Far value is an estimate (decision 013).** No source says how the front bonus combines with
the +50% far sale. LSOS adds the percentages (base 脳 1.85), the lower of adding and
multiplying (base 脳 2.025), and marks the far value "(est.)" with the rule in its tooltip.
- **Only Biker Sell Missions:** the sources name only those, so nightclub goods (Cash Creation,
Organic Produce) keep their value.
- **Higgins Helitours** (+10% Air Freight with a Hangar) is left out: the hangar is tracked
only as owned, without stock or value.

## Why

Adding is the lower guess, so the tracker does not promise money a sale may not pay. A separate
`ownedBonus` keeps the upgrade-based `valueBonus` as it is; both are real cases, but they come
from different places (an upgrade of the business, ownership of another property).
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m
- [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
- [018](018-money-fronts.md) Money Fronts: one Car Wash safe card on the Safes tab, paid by the fronts and other businesses, heat per front (est.), missions and legal jobs
- [019](019-owned-value-bonus.md) Money Front value bonus: owning Car Wash or Smoke on the Water adds 35% to Counterfeit Cash or Weed, far value adds the bonuses (est.), Helitours left out
18 changes: 13 additions & 5 deletions src/components/BusinessProduction.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
onresync: (bars: Bars) => void
/** What bought upgrades add to the value, e.g. 0.05 for a named product. */
bonus?: number
/** The far value adds an owned-property bonus to the far bonus by estimate (decision 019). */
farEstimated?: boolean
/** The last production boost, if the business has one (kept after it ends). */
boost?: Boost
/** Called with the new boost, or undefined to take back one started by mistake. */
Expand All @@ -50,13 +52,22 @@
editing,
onresync,
bonus = 0,
farEstimated = false,
boost,
onboost = () => {},
}: Props = $props()

const current = $derived(settleBars(bars, now, SUPPLY_PURCHASE.fillsTo))
const units = $derived(stockUnits(current, production))
const value = $derived(rate ? stockValue(current, production, rate, bonus) : null)
const farTitle = $derived(
value &&
`Sold ${production.farSale?.label}; ${formatValue(value.nearby, 'money')} sold nearby` +
(farEstimated
? `. Estimate: the ${production.ownedBonus?.label} bonus and the far-sale bonus are ` +
'added, the lower of the two ways they could combine'
: ''),
)
const boosting = $derived(boostRunning(boost, production.boost, now))
const nextBoost = $derived(production.boost ? nextBoostAt(boost, production.boost) : 0)
const ahead = $derived(
Expand Down Expand Up @@ -94,11 +105,8 @@
<span class="label">{est('Value', estimated.value || estimated.production)}</span>
{#if value.far !== null}
<!-- Far sales pay more, so that is the headline; nearby is the alternative. -->
<span
class="value"
title="Sold {production.farSale?.label}; {formatValue(value.nearby, 'money')} sold nearby"
>
{formatValue(value.far, 'money')} far
<span class="value" title={farTitle}>
{formatValue(value.far, 'money')} far{farEstimated ? ' (est.)' : ''}
<span class="alt">路 {formatValue(value.nearby, 'money')} nearby</span>
</span>
{:else}
Expand Down
2 changes: 2 additions & 0 deletions src/components/Businesses.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
matchingRate,
rateFor,
valueBonus,
farValueEstimated,
type Boost,
type BoostsById,
type BusinessBarsById,
Expand Down Expand Up @@ -255,6 +256,7 @@
editing={editing === property.id}
onresync={(bars) => onresync(property.id, bars)}
bonus={valueBonus(production, owned, property)}
farEstimated={farValueEstimated(production, owned)}
boost={boosts[property.id]}
onboost={(boost) => onboost(property.id, boost)}
/>
Expand Down
53 changes: 43 additions & 10 deletions src/data/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface Production {
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 }
/** Owning another property raises the sale value, e.g. a Money Front (decision 019). */
ownedBonus?: OwnedBonus
/** False if the business cannot be raided. */
raids?: false
/** A production boost the player can start in the business. */
Expand All @@ -47,6 +49,17 @@ export interface Production {
checked: string
}

/** A sale value bonus for owning another property, with its own source. */
export interface OwnedBonus {
/** The property in `PROPERTIES` that must be owned. */
propertyId: string
/** Name shown with the value, e.g. "Hands On Car Wash". */
label: string
/** E.g. 0.35 for +35%. */
bonus: number
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 @@ -147,16 +160,36 @@ export const PRODUCTION: readonly Production[] = [
' The wiki gives 2h 30m for a full supply bar, 8 bins at 18 minutes is 2h 24m; the ' +
'per-bin time is used.',
),
mc('mc-cash', 'stacks', 40, [
{ minutesPerUnit: 12, unitValue: 4_725 },
{ minutesPerUnit: 10, unitValue: 5_425 },
{ minutesPerUnit: 8, unitValue: 6_125, unitsPerFullSupplies: 20 },
]),
mc('mc-weed', 'bundles', 80, [
{ minutesPerUnit: 6, unitValue: 2_025 },
{ minutesPerUnit: 5, unitValue: 2_325 },
{ minutesPerUnit: 4, unitValue: 2_625, unitsPerFullSupplies: 50 },
]),
{
...mc('mc-cash', 'stacks', 40, [
{ minutesPerUnit: 12, unitValue: 4_725 },
{ minutesPerUnit: 10, unitValue: 5_425 },
{ minutesPerUnit: 8, unitValue: 6_125, unitsPerFullSupplies: 20 },
]),
ownedBonus: {
propertyId: 'car-wash',
label: 'Hands On Car Wash',
bonus: 0.35,
source:
'gta.wiki, Hands On Car Wash (GTA Online): owning it raises the value of Counterfeit ' +
'Cash Factory product by 35% in Biker Sell Missions.',
},
},
{
...mc('mc-weed', 'bundles', 80, [
{ minutesPerUnit: 6, unitValue: 2_025 },
{ minutesPerUnit: 5, unitValue: 2_325 },
{ minutesPerUnit: 4, unitValue: 2_625, unitsPerFullSupplies: 50 },
]),
ownedBonus: {
propertyId: 'smoke-on-the-water',
label: 'Smoke on the Water',
bonus: 0.35,
source:
'gta.wiki, Smoke on the Water (GTA Online): owning it raises Weed Farm product value ' +
'by 35% in Biker Sell Missions.',
},
},
mc('mc-documents', 'boxes', 60, [
{ minutesPerUnit: 5, unitValue: 1_350 },
{ minutesPerUnit: 4, unitValue: 1_550 },
Expand Down
40 changes: 40 additions & 0 deletions src/lib/production.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PRODUCTION, type Production } from '../data/production'
import { PROPERTIES } from '../data/properties'
import {
estimatedParts,
farValueEstimated,
forecast,
matchingRate,
parseBusinessBars,
Expand Down Expand Up @@ -192,6 +193,45 @@ describe('stock value', () => {
})
})

describe('money front value bonus', () => {
const cash = PRODUCTION.find((p) => p.propertyId === 'mc-cash')!
const weed = PRODUCTION.find((p) => p.propertyId === 'mc-weed')!
const both = { equipment: true, staff: true }
const full = { stock: 100 }

function valueOf(production: Production, owned: OwnedProperties) {
const factory = property(production.propertyId)
const rate = rateFor(production, owned, factory)!
return stockValue(full, production, rate, valueBonus(production, owned, factory))
}

it('adds 35% to the nearby value when the front is owned', () => {
const factory = owning('mc-cash', both)
expect(valueOf(cash, factory)).toEqual({ nearby: 245_000, far: 367_500 })
expect(farValueEstimated(cash, factory)).toBe(false)
// $6,125 脳 1.35 = $8,268.75, paid as whole dollars per stack.
const withWash = { ...factory, 'car-wash': { owned: true, upgrades: {} } }
expect(valueBonus(cash, withWash, property('mc-cash'))).toBe(0.35)
expect(valueOf(cash, withWash).nearby).toBe(40 * 8_268)
})

it('adds the far bonus on the base value, not on top of the front bonus', () => {
const owned = {
...owning('mc-weed', both),
'smoke-on-the-water': { owned: true, upgrades: {} },
}
// 80 脳 $3,543 nearby, plus 50% of 80 脳 $2,625.
expect(valueOf(weed, owned)).toEqual({ nearby: 283_440, far: 283_440 + 105_000 })
expect(farValueEstimated(weed, owned)).toBe(true)
})

it('ignores a front that belongs to another business', () => {
const owned = { ...owning('mc-weed', both), 'car-wash': { owned: true, upgrades: {} } }
expect(valueBonus(weed, owned, property('mc-weed'))).toBe(0)
expect(farValueEstimated(weed, owned)).toBe(false)
})
})

describe('produceAll', () => {
const owned = owning('bunker', { equipment: true, staff: true })

Expand Down
33 changes: 26 additions & 7 deletions src/lib/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,33 @@ export function stockUnits(bars: Bars, production: Production): number {
return Math.floor((bars.stock * production.maxUnits) / 100 + 1e-6)
}

/** What bought upgrades add to the sale value, e.g. 0.05 for a named product. */
/**
* What bought upgrades and owned properties add to the sale value, e.g. 0.05 for a named
* product or 0.35 for a Money Front. No business has both yet.
*/
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
const named = production.valueBonus
const upgrade = named && property.upgrades.find((u) => u.id === named.upgrade)
const fromUpgrade = named && upgrade && upgradeValue(owned, property.id, upgrade) === true
const fromOwned = ownedBonusApplies(production, owned)
return (fromUpgrade ? named.bonus : 0) + (fromOwned ? production.ownedBonus!.bonus : 0)
}

/** Whether the property behind `ownedBonus` is owned, so the bonus is in the value. */
export function ownedBonusApplies(production: Production, owned: OwnedProperties): boolean {
return production.ownedBonus !== undefined && isOwned(owned, production.ownedBonus.propertyId)
}

/**
* Whether the far value is an estimate (decision 013): no source says how an owned-property
* bonus combines with the far-sale bonus; LSOS adds them, the lower guess (decision 019).
*/
export function farValueEstimated(production: Production, owned: OwnedProperties): boolean {
return production.farSale !== undefined && ownedBonusApplies(production, owned)
}

/** Sale value of the stock nearby, and far if a far sale pays more (null if not). */
Expand All @@ -164,8 +181,10 @@ export function stockValue(
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
const units = stockUnits(bars, production)
const nearby = units * Math.floor(rate.unitValue * (1 + bonus))
// The far bonus adds its share of the base value; with no other bonus that is nearby 脳 1.5.
const far = production.farSale ? nearby + units * rate.unitValue * production.farSale.bonus : null
return { nearby, far }
}

Expand Down
Loading