From 712e3fe3b2ccb70c5b35a71fd4699f5d093d65eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:25:37 +0000 Subject: [PATCH] Add Money Front value bonus to Counterfeit Cash and Weed Farm Owning Hands On Car Wash or Smoke on the Water adds 35% to the sale value of the matching biker business (gta.wiki). The far value adds the front and far-sale percentages and is marked as an estimate, since no source says how they combine. Higgins Helitours is left out: the hangar has no tracked stock. Decision 019. Closes #49 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/019-owned-value-bonus.md | 25 +++++++++++ docs/decisions/README.md | 1 + src/components/BusinessProduction.svelte | 18 +++++--- src/components/Businesses.svelte | 2 + src/data/production.ts | 53 +++++++++++++++++++----- src/lib/production.test.ts | 40 ++++++++++++++++++ src/lib/production.ts | 33 +++++++++++---- 8 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 docs/decisions/019-owned-value-bonus.md 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 } }