diff --git a/CLAUDE.md b/CLAUDE.md
index 766e44d..3673ee7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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/` |
diff --git a/docs/decisions/019-owned-value-bonus.md b/docs/decisions/019-owned-value-bonus.md
new file mode 100644
index 0000000..1502029
--- /dev/null
+++ b/docs/decisions/019-owned-value-bonus.md
@@ -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).
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 304cc22..06c64ca 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -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
diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte
index 671befd..a269662 100644
--- a/src/components/BusinessProduction.svelte
+++ b/src/components/BusinessProduction.svelte
@@ -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. */
@@ -50,6 +52,7 @@
editing,
onresync,
bonus = 0,
+ farEstimated = false,
boost,
onboost = () => {},
}: Props = $props()
@@ -57,6 +60,14 @@
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(
@@ -94,11 +105,8 @@
{est('Value', estimated.value || estimated.production)}
{#if value.far !== null}
-
- {formatValue(value.far, 'money')} far
+
+ {formatValue(value.far, 'money')} far{farEstimated ? ' (est.)' : ''}
· {formatValue(value.nearby, 'money')} nearby
{:else}
diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte
index 3c192d6..1ab5bca 100644
--- a/src/components/Businesses.svelte
+++ b/src/components/Businesses.svelte
@@ -16,6 +16,7 @@
matchingRate,
rateFor,
valueBonus,
+ farValueEstimated,
type Boost,
type BoostsById,
type BusinessBarsById,
@@ -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)}
/>
diff --git a/src/data/production.ts b/src/data/production.ts
index 6016ff7..57f544f 100644
--- a/src/data/production.ts
+++ b/src/data/production.ts
@@ -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. */
@@ -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.
@@ -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 },
diff --git a/src/lib/production.test.ts b/src/lib/production.test.ts
index 658b43d..ca11245 100644
--- a/src/lib/production.test.ts
+++ b/src/lib/production.test.ts
@@ -3,6 +3,7 @@ import { PRODUCTION, type Production } from '../data/production'
import { PROPERTIES } from '../data/properties'
import {
estimatedParts,
+ farValueEstimated,
forecast,
matchingRate,
parseBusinessBars,
@@ -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 })
diff --git a/src/lib/production.ts b/src/lib/production.ts
index 1dccd79..242d6c2 100644
--- a/src/lib/production.ts
+++ b/src/lib/production.ts
@@ -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). */
@@ -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 }
}