From 5bdb8040ca6ac6a171eb8dfd5cf8a30c1eca2ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:01:55 +0000 Subject: [PATCH 1/3] Money Fronts: Car Wash safe card with heat per front on the Safes tab One card for the shared safe in Hands On Car Wash: paid every in-game day by the three fronts ($750, $1,500 after 15 laundering missions) and by every other business owned, up to $100,000. Heat bar per front with laundering, Faber and legal-job buttons; a front at 100% stops its pay, the Car Wash at 100% stops the safe. Heat rise is marked as an estimate. Data version 9 saves heat with the safe. Decision 018. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/018-money-fronts.md | 40 +++++ docs/decisions/README.md | 1 + src/components/MoneyFrontsCard.svelte | 211 ++++++++++++++++++++++++++ src/components/Safes.svelte | 46 +++++- src/components/ui/Kit.svelte | 1 + src/components/ui/Meter.svelte | 12 +- src/data/fronts.ts | 88 +++++++++++ src/data/properties.ts | 60 +++++++- src/lib/fronts.test.ts | 192 +++++++++++++++++++++++ src/lib/fronts.ts | 200 ++++++++++++++++++++++++ src/lib/safes.svelte.ts | 13 +- src/lib/safes.test.ts | 5 + src/lib/safes.ts | 17 ++- src/lib/state.test.ts | 38 ++++- src/lib/state.ts | 5 +- 16 files changed, 907 insertions(+), 23 deletions(-) create mode 100644 docs/decisions/018-money-fronts.md create mode 100644 src/components/MoneyFrontsCard.svelte create mode 100644 src/data/fronts.ts create mode 100644 src/lib/fronts.test.ts create mode 100644 src/lib/fronts.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1a8a269..2bc3193 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Business card order (drag, keys) | `lib/order.ts`, `ui/Panel` `move` (decision 014) | | 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) | | Theme tokens | `src/app.css` (decision 009) | | CI, templates | `.github/` | | Skills: `handoff`, `finish-pr`; agent: `scout` | `.claude/` | diff --git a/docs/decisions/018-money-fronts.md b/docs/decisions/018-money-fronts.md new file mode 100644 index 0000000..b61dd7b --- /dev/null +++ b/docs/decisions/018-money-fronts.md @@ -0,0 +1,40 @@ +# 018: Money Fronts safe and heat + +2026-10-15, issue #11. Builds on decisions 004, 010, 011, 013 and 016. + +## Decision + +- **One card on the Safes tab,** not its own tab or the Businesses tab. The game pays all three + fronts into one safe in the Hands On Car Wash, so a tab would hold a single card, and the safe + works like the others (decision 016). The card shows a heat bar per owned front, the safe, the + payout, the next payout, time until full and time until the Car Wash reaches 100% heat. +- **Properties:** the three fronts join the catalog with `card: false` and a "Laundering + missions done" count (15 doubles that front's pay). Vehicle Warehouse, Auto Shop, Bail + Enforcement Office and McKenzie Field Hangar join as owned-only properties because they add + to the safe. The Special Cargo Warehouse gets a "Largest owned" choice (default Large): the + game counts only the largest warehouse. The card shows only while the Car Wash is owned. +- **Payout** (`src/data/fronts.ts`), every in-game day (48 minutes of play time), up to + $100,000: $750 per front ($1,500 after 15 laundering missions) plus a fixed amount for each + other business owned, from the in-game safe screen (data comment has the sources). A front at + 100% heat pays nothing; the Car Wash at 100% stops the whole safe. LSOS counts every owned + business; the game skips biker businesses and the Bunker while paused or not set up, which the + card says. +- **Heat** per front, saved with the safe (`heat` by front id, data version 9). At the end of + each day, after the payout, every front below 100% gains 10%, one less per other front below + 100% (8% with all three). A laundering mission or Mr. Faber job adds 10% to the front it was + launched from; a laundering mission counts toward that front's 15, a Faber job toward all + three. Legal work clears the front's heat. The rise comes from a player's notes, and which + fronts rise is LSOS's reading of them, so heat is marked "(est.)" (decision 013). +- **"Full in"** assumes heat stays where it is now, like popularity on the other safes. A + separate "Car Wash 100% heat in (est.)" countdown says when the safe would stop. +- **Edit mode** adds the resync controls (decision 010: heat values and bars, safe money with + Collected/Full, "Payout just arrived" with the half-day rule of decision 016) and per front + "Laundering +10%", "Faber job +10%" and "Legal job" buttons. +- **Data version 9** with an unchanged migration: an older LSOS would silently drop the heat. + +## Why + +Following the game's own shape (one safe, heat per front) keeps the card small and the numbers +exact where sources are exact. Logging missions in one click keeps both heat and the doubling +count right without typing. Full-in on today's heat stays exact and matches decision 016; the +heat countdown covers the risk that the safe stops. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a85f6e7..304cc22 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -28,3 +28,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [015](015-nightclub-warehouse.md) Nightclub warehouse: goods per technician on play time, saved in crates, unlocked by owning the business, best technician setup by value per hour - [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 diff --git a/src/components/MoneyFrontsCard.svelte b/src/components/MoneyFrontsCard.svelte new file mode 100644 index 0000000..8c7b4d8 --- /dev/null +++ b/src/components/MoneyFrontsCard.svelte @@ -0,0 +1,211 @@ + + +{#each fronts as front (front.propertyId)} + {@const heat = heatOf(current, front.propertyId)} + {#if editing} + onresync(setHeat(current, front.propertyId, value))} + > + onresync(setHeat(current, front.propertyId, value))} + /> + +
+ + + +
+ {:else} + + {/if} +{/each} + +{#if carWashStopped} +

+ Safe stopped. The Car Wash is at 100% heat: the safe gets nothing until you do a + legal job there. +

+{:else if stopped.length > 0} +

+ Front stopped. + {stopped.map((f) => `${f.label} (${money(frontPay(f, owned, catalog))})`).join(', ')} + {stopped.length === 1 ? 'is' : 'are'} at 100% heat and pay nothing until a legal job there. +

+{/if} + +{#if editing} + onresync({ ...current, money: value })} + /> +{:else} + +{/if} + +
+ Payout + {money(parts.fronts + parts.others)} a day +
+

+ Fronts {money(parts.fronts)} + your other businesses {money(parts.others)}. Biker businesses and + the Bunker only count while set up and not paused. +

+ + +{#if editing} +
+ +

Press when the game says money was added to this safe.

+
+{/if} +{#if full === null} +

Never full: this safe gets nothing a day.

+{:else} + +{/if} +{#if stopsIn !== null && stopsIn > 0} + +{/if} + +

{FRONTS_ESTIMATE}

+ + diff --git a/src/components/Safes.svelte b/src/components/Safes.svelte index e3558a4..9db9b68 100644 --- a/src/components/Safes.svelte +++ b/src/components/Safes.svelte @@ -1,12 +1,15 @@ -{#if shown.length === 0} +{#if shown.length === 0 && !fronts}

- You do not own a property with a safe yet: {safes - .map((safe) => catalog.find((p) => p.id === safe.propertyId)?.label) + You do not own a property with a safe yet: {[CAR_WASH, ...safes.map((safe) => safe.propertyId)] + .map((id) => catalog.find((p) => p.id === id)?.label) .join(', ')}. Mark what you own in Settings.

{:else}
+ {#if fronts} + + {#if open === CAR_WASH} + {#each frontProperties as property (property.id)} + + {/each} + {/if} + onresync(CAR_WASH, state)} + /> + {#snippet footer()} + + (open = open === CAR_WASH ? null : CAR_WASH)} + /> + {/snippet} + + {/if} {#each shown as { safe, property } (property.id)} {#if open === property.id} diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index 842eded..f49f74f 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -77,6 +77,7 @@ + {#snippet footer()} diff --git a/src/components/ui/Meter.svelte b/src/components/ui/Meter.svelte index a60ce5a..4c91f37 100644 --- a/src/components/ui/Meter.svelte +++ b/src/components/ui/Meter.svelte @@ -5,8 +5,8 @@ label: string value: number max: number - /** stock: green, supplies: blue, as on the in-game business screens. */ - kind?: 'stock' | 'supplies' + /** stock: green, supplies: blue, as on the in-game business screens; heat: amber. */ + kind?: 'stock' | 'supplies' | 'heat' /** Text shown next to the label, e.g. "40 / 100" or "$120,000". Defaults to a percentage. */ valueText?: string /** Only the bar, for when the label and value are shown next to it (e.g. by EditableValue). */ @@ -178,6 +178,10 @@ color: var(--supplies-text); } + .heat .value { + color: var(--warn); + } + .track { background: var(--track); border-radius: var(--radius); @@ -203,6 +207,10 @@ background: var(--supplies); } + .heat .fill { + background: var(--warn); + } + .segments { display: flex; gap: 4px; diff --git a/src/data/fronts.ts b/src/data/fronts.ts new file mode 100644 index 0000000..8c39baf --- /dev/null +++ b/src/data/fronts.ts @@ -0,0 +1,88 @@ +// Money Fronts: one safe in the Hands On Car Wash, paid by the three fronts and by every other +// business owned, and a heat bar per front (decision 004, decision 018). + +export const FRONTS_CHECKED = '2026-10-15' + +/** The front whose safe holds the money; without it there is no safe. */ +export const CAR_WASH = 'car-wash' + +export interface Front { + propertyId: string + label: string + /** Paid into the safe per in-game day while the front's heat is below 100%. */ + amount: number + /** Paid instead after `doubleAfter` laundering missions for this front. */ + doubled: number +} + +/** Laundering missions for a front (Mr. Faber jobs count for every front) that double its pay. */ +export const DOUBLE_AFTER = 15 +/** The laundering-mission count upgrade on each front (data/properties.ts). */ +export const MISSIONS_UPGRADE = 'laundering' + +export const FRONTS: readonly Front[] = [ + { propertyId: CAR_WASH, label: 'Hands On Car Wash', amount: 750, doubled: 1_500 }, + { propertyId: 'helitours', label: 'Higgins Helitours', amount: 750, doubled: 1_500 }, + { propertyId: 'smoke-on-the-water', label: 'Smoke on the Water', amount: 750, doubled: 1_500 }, +] + +/** Most money the safe holds. */ +export const FRONTS_CAPACITY = 100_000 + +/** What another owned business adds to the safe per in-game day. */ +export interface Share { + propertyId: string + amount: number +} + +/** The Special Cargo Warehouse counts once, by the largest size owned (an upgrade on it). */ +export const WAREHOUSE_SIZE_UPGRADE = 'largest' +export const WAREHOUSE_SHARES: Readonly> = { + large: 1_500, + medium: 1_250, + small: 750, +} + +export const SHARES: readonly Share[] = [ + { propertyId: 'vehicle-warehouse', amount: 1_500 }, + { propertyId: 'mc-cash', amount: 1_250 }, + { propertyId: 'mc-cocaine', amount: 1_500 }, + { propertyId: 'mc-documents', amount: 1_000 }, + { propertyId: 'mc-meth', amount: 1_250 }, + { propertyId: 'mc-weed', amount: 1_000 }, + { propertyId: 'bunker', amount: 1_500 }, + { propertyId: 'hangar', amount: 1_500 }, + { propertyId: 'nightclub', amount: 1_500 }, + { propertyId: 'arcade', amount: 1_500 }, + { propertyId: 'auto-shop', amount: 1_500 }, + { propertyId: 'agency', amount: 1_750 }, + { propertyId: 'acid-lab', amount: 1_250 }, + { propertyId: 'salvage-yard', amount: 1_500 }, + { propertyId: 'bail-office', amount: 1_500 }, + { propertyId: 'garment-factory', amount: 1_750 }, + { propertyId: 'mckenzie-hangar', amount: 1_500 }, +] + +/** Heat added to a front by a laundering mission or a Mr. Faber job launched from it. */ +export const HEAT_PER_MISSION = 10 + +/** + * Heat each front below 100% gains per in-game day: 10% with only one front below 100%, one + * less for each other front below 100% (8% with all three). + */ +export const HEAT_RISE_ALONE = 10 + +export const FRONTS_ESTIMATE = + 'Heat is a best estimate: how it rises comes from players, not the game files, and LSOS ' + + 'raises every front below 100% at the end of each in-game day.' + +export const FRONTS_SOURCE = + 'gta.wiki, Hands On Car Wash (GTA Online): income of all three fronts is collected from the ' + + 'safe in the car wash, $750 each, $1,500 after 15 Money Laundering missions for a front, Mr. ' + + 'Faber Work counts for all three, the safe holds $100,000. gta.wiki, Higgins Helitours and ' + + 'Smoke on the Water (GTA Online): passive income only while heat is below 100%. gta.wiki, GTA ' + + 'Online: Money Fronts: heat per business, raised by laundering, lowered by legitimate work. ' + + 'Payout every 48 minutes and the amount per other business (in-game safe screen): Harry ' + + '(@harryodini588) on X, 2025-06-18; warehouses count by the largest size. Heat: PLTytus on ' + + 'X, 2025-06-20: +10% per laundering or Faber mission, legal work resets it, +10/9/8% every 48 ' + + 'minutes, 100% at the car wash stops the safe, at another front only its own pay.' diff --git a/src/data/properties.ts b/src/data/properties.ts index 882558b..886d994 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -43,6 +43,13 @@ import { NIGHTCLUB_CHECKED, NIGHTCLUB_SOURCE, } from './nightclub' +import { + FRONTS, + FRONTS_CHECKED, + FRONTS_SOURCE, + MISSIONS_UPGRADE, + WAREHOUSE_SIZE_UPGRADE, +} from './fronts' import { ARCADE_SLOTS, SAFES, SAFES_CHECKED } from './safes' // Equipment and staff speed up production and raise the value; equipment also makes supplies @@ -83,14 +90,26 @@ export const PROPERTIES: readonly Property[] = [ 'Acid Product: a unique name adds 5% to the sale value.', checked: '2026-10-15', }, - // Only owned or not: owning either unlocks Cargo and Shipments in the nightclub (issue #7). + // Owning either unlocks Cargo and Shipments in the nightclub (issue #7). The largest warehouse + // owned sets what it adds to the Money Fronts safe (issue #11). { id: 'special-cargo', label: 'Special Cargo Warehouse', - upgrades: [], + upgrades: [ + { + kind: 'choice', + id: WAREHOUSE_SIZE_UPGRADE, + label: 'Largest owned', + options: [ + { id: 'large', label: 'Large' }, + { id: 'medium', label: 'Medium' }, + { id: 'small', label: 'Small' }, + ], + }, + ], card: false, - source: NIGHTCLUB_SOURCE, - checked: NIGHTCLUB_CHECKED, + source: `${NIGHTCLUB_SOURCE} ${FRONTS_SOURCE}`, + checked: FRONTS_CHECKED, }, { id: 'hangar', @@ -177,6 +196,39 @@ export const PROPERTIES: readonly Property[] = [ source: SAFES.find((safe) => safe.propertyId === 'salvage-yard')!.source, checked: SAFES_CHECKED, }, + // Money Fronts (issue #11, decision 018): the Car Wash holds the safe, all three have heat. + ...FRONTS.map((front): Property => ({ + id: front.propertyId, + label: front.label, + // Mr. Faber jobs count for every front. + upgrades: [ + { + kind: 'count', + id: MISSIONS_UPGRADE, + label: 'Laundering missions done (15 doubles its pay)', + default: 0, + }, + ], + card: false, + source: FRONTS_SOURCE, + checked: FRONTS_CHECKED, + })), + // Only owned or not: each adds to the Money Fronts safe (issue #11). + ...( + [ + ['vehicle-warehouse', 'Vehicle Warehouse'], + ['auto-shop', 'Auto Shop'], + ['bail-office', 'Bail Enforcement Office'], + ['mckenzie-hangar', 'McKenzie Field Hangar'], + ] as const + ).map(([id, label]): Property => ({ + id, + label, + upgrades: [], + card: false, + source: FRONTS_SOURCE, + checked: FRONTS_CHECKED, + })), ] function mc(id: string, label: string): Property { diff --git a/src/lib/fronts.test.ts b/src/lib/fronts.test.ts new file mode 100644 index 0000000..3ac7eb7 --- /dev/null +++ b/src/lib/fronts.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' +import { FRONTS, SHARES } from '../data/fronts' +import { PROPERTIES } from '../data/properties' +import { + advanceFronts, + dailyPayout, + emptyFronts, + fullInMs, + heatOf, + heatRise, + legalJob, + maxHeatInMs, + mission, + payoutParts, + setHeat, + syncFronts, +} from './fronts' +import type { OwnedProperties, UpgradeValue } from './properties' +import { DAY_MS, type SafeState } from './safes' + +const MIN = 60_000 +const ALL_FRONTS = ['car-wash', 'helitours', 'smoke-on-the-water'] + +function owning( + ids: readonly string[], + upgrades: Record> = {}, +): OwnedProperties { + return Object.fromEntries(ids.map((id) => [id, { owned: true, upgrades: upgrades[id] ?? {} }])) +} + +const safe = (money: number, heat: Record = {}, sinceMs = 0): SafeState => ({ + money, + sinceMs, + heat, +}) + +describe('data', () => { + it('has a property for every front and every business that adds to the safe', () => { + for (const id of [...FRONTS.map((f) => f.propertyId), ...SHARES.map((s) => s.propertyId)]) { + expect(PROPERTIES.find((p) => p.id === id)).toBeDefined() + } + }) + + it('adds up to the in-game totals: $28,000, and $30,250 with all fronts doubled', () => { + const everything = owning(PROPERTIES.map((p) => p.id)) + expect(dailyPayout(emptyFronts(), everything, PROPERTIES)).toBe(28_000) + const doubled = owning( + PROPERTIES.map((p) => p.id), + Object.fromEntries(ALL_FRONTS.map((id) => [id, { laundering: 15 }])), + ) + expect(dailyPayout(emptyFronts(), doubled, PROPERTIES)).toBe(30_250) + }) +}) + +describe('payoutParts', () => { + it('pays nothing without the car wash', () => { + expect(dailyPayout(emptyFronts(), owning(['helitours', 'bunker']), PROPERTIES)).toBe(0) + }) + + it('pays $750 per front, $1,500 after 15 laundering missions', () => { + const owned = owning(ALL_FRONTS, { + helitours: { laundering: 15 }, + 'car-wash': { laundering: 14 }, + }) + expect(payoutParts(emptyFronts(), owned, PROPERTIES)).toEqual({ fronts: 3_000, others: 0 }) + }) + + it('adds other businesses, the warehouse by its largest size', () => { + const owned = owning(['car-wash', 'agency', 'special-cargo'], { + 'special-cargo': { largest: 'medium' }, + }) + expect(payoutParts(emptyFronts(), owned, PROPERTIES)).toEqual({ fronts: 750, others: 3_000 }) + const large = owning(['car-wash', 'special-cargo']) + expect(payoutParts(emptyFronts(), large, PROPERTIES).others).toBe(1_500) + }) + + it('drops a front at 100% heat, and everything when the car wash is at 100%', () => { + const owned = owning([...ALL_FRONTS, 'bunker']) + expect(dailyPayout(safe(0, { helitours: 100 }), owned, PROPERTIES)).toBe(1_500 + 1_500) + expect(dailyPayout(safe(0, { 'car-wash': 100 }), owned, PROPERTIES)).toBe(0) + }) +}) + +describe('heatRise', () => { + it('is 10% alone, one less per other front below 100%', () => { + expect(heatRise(emptyFronts(), owning(['car-wash']))).toBe(10) + expect(heatRise(emptyFronts(), owning(['car-wash', 'helitours']))).toBe(9) + expect(heatRise(emptyFronts(), owning(ALL_FRONTS))).toBe(8) + expect(heatRise(safe(0, { helitours: 100 }), owning(ALL_FRONTS))).toBe(9) + }) +}) + +describe('advanceFronts', () => { + const owned = owning([...ALL_FRONTS, 'nightclub']) + + it('pays at the end of each in-game day, then raises heat', () => { + const start = safe(0, { helitours: 95 }) + expect(advanceFronts(start, owned, PROPERTIES, DAY_MS - 1)).toEqual({ + ...start, + sinceMs: DAY_MS - 1, + }) + const day = advanceFronts(start, owned, PROPERTIES, DAY_MS + 5 * MIN) + expect(day.money).toBe(750 * 3 + 1_500) + expect(day.sinceMs).toBe(5 * MIN) + expect(day.heat).toEqual({ 'car-wash': 8, helitours: 100, 'smoke-on-the-water': 8 }) + // Next day Helitours no longer pays and the others rise by 9%. + const next = advanceFronts(day, owned, PROPERTIES, DAY_MS) + expect(next.money).toBe(3_750 + 3_000) + expect(next.heat).toEqual({ 'car-wash': 17, helitours: 100, 'smoke-on-the-water': 17 }) + }) + + it('stops at the $100,000 cap', () => { + const near = advanceFronts(safe(99_000), owned, PROPERTIES, DAY_MS) + expect(near.money).toBe(100_000) + }) + + it('keeps the same object when nothing is owned or no time passed', () => { + const start = emptyFronts() + expect(advanceFronts(start, {}, PROPERTIES, DAY_MS)).toBe(start) + expect(advanceFronts(start, owned, PROPERTIES, 0)).toBe(start) + }) +}) + +describe('syncFronts', () => { + const owned = owning(['car-wash']) + + it('only restarts the day when the payout was counted moments ago', () => { + expect(syncFronts(safe(750, {}, 10 * MIN), owned, PROPERTIES)).toEqual(safe(750)) + }) + + it('pays now when the payout was still to come', () => { + expect(syncFronts(safe(0, {}, 40 * MIN), owned, PROPERTIES)).toEqual( + safe(750, { 'car-wash': 10 }), + ) + }) +}) + +describe('fullInMs', () => { + const owned = owning(['car-wash'], { 'car-wash': { laundering: 15 } }) + + it('counts whole days at the payout of now', () => { + // $1,500 a day: 97,000 needs 2 payouts, the first in 30 minutes. + expect(fullInMs(safe(97_000, {}, 18 * MIN), owned, PROPERTIES)).toBe(30 * MIN + DAY_MS) + expect(fullInMs(safe(100_000), owned, PROPERTIES)).toBe(0) + }) + + it('is null when nothing is paid', () => { + expect(fullInMs(safe(0, { 'car-wash': 100 }), owned, PROPERTIES)).toBeNull() + }) +}) + +describe('maxHeatInMs', () => { + it('counts days of rising heat until 100%', () => { + // 10% a day alone: 75% reaches 100% at the third payout. + const alone = owning(['car-wash']) + expect(maxHeatInMs(safe(0, { 'car-wash': 75 }, 8 * MIN), alone, 'car-wash')).toBe( + 40 * MIN + 2 * DAY_MS, + ) + expect(maxHeatInMs(safe(0, { 'car-wash': 100 }), alone, 'car-wash')).toBe(0) + expect(maxHeatInMs(emptyFronts(), alone, 'helitours')).toBeNull() + }) + + it('speeds up as other fronts reach 100%', () => { + // 8% while all three are cool, 9% once Helitours is at 100%: 84, 93, 100. + const owned = owning(ALL_FRONTS) + const start = safe(0, { 'car-wash': 76, helitours: 92 }) + expect(maxHeatInMs(start, owned, 'car-wash')).toBe(3 * DAY_MS) + }) +}) + +describe('missions and legal work', () => { + const owned = owning(ALL_FRONTS, { helitours: { laundering: 3 } }) + + it('adds 10% heat, up to 100%, and counts a laundering mission for that front', () => { + const after = mission(safe(0, { helitours: 95 }), owned, PROPERTIES, 'helitours', 'laundering') + expect(heatOf(after.state, 'helitours')).toBe(100) + expect(after.owned.helitours!.upgrades.laundering).toBe(4) + expect(after.owned['car-wash']!.upgrades.laundering).toBeUndefined() + }) + + it('counts a Mr. Faber job for every front, heat only where it was launched', () => { + const after = mission(emptyFronts(), owned, PROPERTIES, 'car-wash', 'faber') + expect(after.state.heat).toEqual({ 'car-wash': 10 }) + expect(ALL_FRONTS.map((id) => after.owned[id]!.upgrades.laundering)).toEqual([1, 4, 1]) + }) + + it('clears heat with legal work', () => { + expect(legalJob(setHeat(emptyFronts(), 'car-wash', 70), 'car-wash').heat).toEqual({ + 'car-wash': 0, + }) + }) +}) diff --git a/src/lib/fronts.ts b/src/lib/fronts.ts new file mode 100644 index 0000000..0a5e0b8 --- /dev/null +++ b/src/lib/fronts.ts @@ -0,0 +1,200 @@ +// Money Fronts (issue #11, decision 018): one safe in the Car Wash paid once per in-game day of +// play time by the fronts and every other business owned, and a heat bar per front that rises +// each day and with missions. Pure logic: play time comes in as an argument (decision 003). +import { + CAR_WASH, + DOUBLE_AFTER, + FRONTS, + FRONTS_CAPACITY, + HEAT_PER_MISSION, + HEAT_RISE_ALONE, + MISSIONS_UPGRADE, + SHARES, + WAREHOUSE_SHARES, + WAREHOUSE_SIZE_UPGRADE, + type Front, +} from '../data/fronts' +import type { Property } from '../data/properties' +import { isOwned, setUpgrade, upgradeValue, type OwnedProperties } from './properties' +import { DAY_MS, nextPayoutInMs, type SafeState } from './safes' + +/** The heat a front is stopped at: no pay from it, and none at all from the Car Wash. */ +export const MAX_HEAT = 100 + +/** A safe nobody has entered yet: empty, a whole day to the first payout, no heat. */ +export function emptyFronts(): SafeState { + return { money: 0, sinceMs: 0, heat: {} } +} + +export function heatOf(state: SafeState, frontId: string): number { + return state.heat?.[frontId] ?? 0 +} + +/** The fronts the user owns; none without the Car Wash, which the others are bought through. */ +export function ownedFronts(owned: OwnedProperties): readonly Front[] { + if (!isOwned(owned, CAR_WASH)) return [] + return FRONTS.filter((front) => isOwned(owned, front.propertyId)) +} + +function missionsDone(owned: OwnedProperties, catalog: readonly Property[], id: string): number { + const upgrade = catalog.find((p) => p.id === id)?.upgrades.find((u) => u.id === MISSIONS_UPGRADE) + return upgrade ? Number(upgradeValue(owned, id, upgrade)) : 0 +} + +/** What a front pays per day while its heat is below 100%. */ +export function frontPay(front: Front, owned: OwnedProperties, catalog: readonly Property[]) { + return missionsDone(owned, catalog, front.propertyId) >= DOUBLE_AFTER + ? front.doubled + : front.amount +} + +/** What the other businesses owned add per day; the warehouse counts by its largest size. */ +export function sharesPay(owned: OwnedProperties, catalog: readonly Property[]): number { + let total = 0 + for (const share of SHARES) if (isOwned(owned, share.propertyId)) total += share.amount + const warehouse = catalog.find((p) => p.id === 'special-cargo') + const size = warehouse?.upgrades.find((u) => u.id === WAREHOUSE_SIZE_UPGRADE) + if (warehouse && size && isOwned(owned, warehouse.id)) { + total += WAREHOUSE_SHARES[String(upgradeValue(owned, warehouse.id, size))] ?? 0 + } + return total +} + +/** The day's payout in two parts: the fronts below 100% heat, and the other businesses. */ +export function payoutParts( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], +): { fronts: number; others: number } { + const fronts = ownedFronts(owned) + if (fronts.length === 0 || heatOf(state, CAR_WASH) >= MAX_HEAT) return { fronts: 0, others: 0 } + let paid = 0 + for (const front of fronts) { + if (heatOf(state, front.propertyId) < MAX_HEAT) paid += frontPay(front, owned, catalog) + } + return { fronts: paid, others: sharesPay(owned, catalog) } +} + +export function dailyPayout( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], +): number { + const { fronts, others } = payoutParts(state, owned, catalog) + return fronts + others +} + +/** Heat each front below 100% gains per day: 10%, one less per other front below 100%. */ +export function heatRise(state: SafeState, owned: OwnedProperties): number { + const cool = ownedFronts(owned).filter((f) => heatOf(state, f.propertyId) < MAX_HEAT).length + return cool === 0 ? 0 : HEAT_RISE_ALONE - (cool - 1) +} + +/** The heat of every front below 100% after an in-game day. */ +function riseHeat(state: SafeState, owned: OwnedProperties): SafeState { + const rise = heatRise(state, owned) + const heat = { ...state.heat } + for (const front of ownedFronts(owned)) { + const now = heatOf(state, front.propertyId) + if (now < MAX_HEAT) heat[front.propertyId] = Math.min(MAX_HEAT, now + rise) + } + return { ...state, heat } +} + +/** The end of an in-game day: the safe is paid up to its capacity, then heat rises. */ +function endOfDay( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], +): SafeState { + const paid = dailyPayout(state, owned, catalog) + const money = + state.money < FRONTS_CAPACITY ? Math.min(FRONTS_CAPACITY, state.money + paid) : state.money + return riseHeat({ ...state, money }, owned) +} + +/** + * The safe after `playMs` of play time. Money already over the capacity (set by hand) stays + * until changed. + */ +export function advanceFronts( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], + playMs: number, +): SafeState { + if (playMs <= 0 || ownedFronts(owned).length === 0) return state + let next = { ...state, sinceMs: state.sinceMs + playMs } + while (next.sinceMs >= DAY_MS) { + next = endOfDay({ ...next, sinceMs: next.sinceMs - DAY_MS }, owned, catalog) + } + return next +} + +/** Right after the game says a payout came in; the same half-day rule as other safes. */ +export function syncFronts( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], +): SafeState { + if (state.sinceMs < DAY_MS / 2) return { ...state, sinceMs: 0 } + return advanceFronts(state, owned, catalog, nextPayoutInMs(state)) +} + +/** + * Play time until the safe is full if heat stays where it is now, like popularity on the other + * safes. 0 when full; null if nothing is paid. + */ +export function fullInMs( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], +): number | null { + if (state.money >= FRONTS_CAPACITY) return 0 + const paid = dailyPayout(state, owned, catalog) + if (paid <= 0) return null + return nextPayoutInMs(state) + (Math.ceil((FRONTS_CAPACITY - state.money) / paid) - 1) * DAY_MS +} + +/** + * Play time until a front reaches 100% heat if no missions or legal jobs are done. 0 when it is + * there; null if the front is not owned. + */ +export function maxHeatInMs(state: SafeState, owned: OwnedProperties, frontId: string) { + if (!ownedFronts(owned).some((f) => f.propertyId === frontId)) return null + let day = state + let ms = nextPayoutInMs(state) + if (heatOf(day, frontId) >= MAX_HEAT) return 0 + // At 8% or more a day, 100% is at most 13 days away. + while (heatOf((day = riseHeat(day, owned)), frontId) < MAX_HEAT) ms += DAY_MS + return ms +} + +/** + * A laundering mission (counts for this front) or a Mr. Faber job (counts for every front) + * launched from `frontId`: its heat rises by 10%. Returns the new safe and the new mission counts. + */ +export function mission( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], + frontId: string, + kind: 'laundering' | 'faber', +): { state: SafeState; owned: OwnedProperties } { + const heat = Math.min(MAX_HEAT, heatOf(state, frontId) + HEAT_PER_MISSION) + let counts = owned + const counted = kind === 'faber' ? ownedFronts(owned).map((f) => f.propertyId) : [frontId] + for (const id of counted) { + counts = setUpgrade(counts, id, MISSIONS_UPGRADE, missionsDone(owned, catalog, id) + 1) + } + return { state: setHeat(state, frontId, heat), owned: counts } +} + +/** Legal work at a front (washing cars, a helitour, a weed delivery) clears its heat. */ +export function legalJob(state: SafeState, frontId: string): SafeState { + return setHeat(state, frontId, 0) +} + +export function setHeat(state: SafeState, frontId: string, heat: number): SafeState { + return { ...state, heat: { ...state.heat, [frontId]: heat } } +} diff --git a/src/lib/safes.svelte.ts b/src/lib/safes.svelte.ts index 3b2bd69..815c482 100644 --- a/src/lib/safes.svelte.ts +++ b/src/lib/safes.svelte.ts @@ -1,11 +1,18 @@ -// Runs property safes on play time (issue #8). Logic lives in safes.ts; this file only wires it -// to the play clock and the store. +// Runs property safes (issue #8) and the Money Fronts safe (issue #11) on play time. Logic lives +// in safes.ts and fronts.ts; this file only wires it to the play clock and the store. import { PROPERTIES } from '../data/properties' +import { CAR_WASH } from '../data/fronts' import { SAFES } from '../data/safes' import { onPlayTime } from './clock.svelte' +import { advanceFronts, emptyFronts } from './fronts' import { advanceSafes } from './safes' import { store } from './store.svelte' onPlayTime((ms) => { - store.state.safes = advanceSafes(store.state.safes, SAFES, PROPERTIES, store.state.properties, ms) + const owned = store.state.properties + let safes = advanceSafes(store.state.safes, SAFES, PROPERTIES, owned, ms) + const fronts = safes[CAR_WASH] ?? emptyFronts() + const after = advanceFronts(fronts, owned, PROPERTIES, ms) + if (after !== fronts) safes = { ...safes, [CAR_WASH]: after } + store.state.safes = safes }) diff --git a/src/lib/safes.test.ts b/src/lib/safes.test.ts index 37e7cfa..3d436f2 100644 --- a/src/lib/safes.test.ts +++ b/src/lib/safes.test.ts @@ -261,6 +261,7 @@ describe('parseSafes', () => { it('keeps valid entries', () => { const data = { nightclub: { money: 10, sinceMs: 5, popularity: 50 }, + 'car-wash': { money: 0, sinceMs: 0, heat: { 'car-wash': 100, helitours: 0 } }, arcade: { money: 0, sinceMs: 0 }, } expect(parseSafes(data)).toEqual(data) @@ -273,5 +274,9 @@ describe('parseSafes', () => { expect(parseSafes({ nightclub: { money: 1, sinceMs: 0, popularity: 120 } })).toBeTypeOf( 'string', ) + expect(parseSafes({ 'car-wash': { money: 1, sinceMs: 0, heat: [] } })).toBeTypeOf('string') + expect(parseSafes({ 'car-wash': { money: 1, sinceMs: 0, heat: { x: -5 } } })).toBeTypeOf( + 'string', + ) }) }) diff --git a/src/lib/safes.ts b/src/lib/safes.ts index 458f637..e86a87d 100644 --- a/src/lib/safes.ts +++ b/src/lib/safes.ts @@ -14,6 +14,8 @@ export interface SafeState { sinceMs: number /** Popularity in percent, for a safe whose payout depends on it. */ popularity?: number + /** Money Fronts safe only: heat in percent by front property id (lib/fronts.ts). */ + heat?: Record } /** Saved safes by property id. */ @@ -196,14 +198,27 @@ export function parseSafes(data: unknown): SafesById | string { const state: SafeState = { money: entry.money, sinceMs: entry.sinceMs } if (entry.popularity !== undefined) { const { popularity } = entry - if (typeof popularity !== 'number' || !(popularity >= 0 && popularity <= 100)) return bad + if (!isPercent(popularity)) return bad state.popularity = popularity } + if (entry.heat !== undefined) { + if (!isRecord(entry.heat)) return bad + const heat: Record = {} + for (const [front, value] of Object.entries(entry.heat)) { + if (!isPercent(value)) return bad + heat[front] = value + } + state.heat = heat + } result[id] = state } return result } +function isPercent(value: unknown): value is number { + return typeof value === 'number' && value >= 0 && value <= 100 +} + function isAmount(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 } diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index d432603..9465fcc 100644 --- a/src/lib/state.test.ts +++ b/src/lib/state.test.ts @@ -25,7 +25,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 8, + version: 9, clock: { trackOnOpen: false }, properties: {}, businesses: {}, @@ -43,7 +43,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 8, + version: 9, clock: { trackOnOpen: true }, properties: {}, businesses: {}, @@ -64,7 +64,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 8, + version: 9, clock: { trackOnOpen: false }, properties, businesses: {}, @@ -86,7 +86,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 8, + version: 9, clock: { trackOnOpen: true }, properties, businesses, @@ -110,7 +110,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 8, nightclub: {}, safes: {}, boosts: {} }, + state: { ...sample, version: 9, nightclub: {}, safes: {}, boosts: {} }, fromVersion: 5, }) }) @@ -126,7 +126,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 8, safes: {}, boosts: {} }, + state: { ...sample, version: 9, safes: {}, boosts: {} }, fromVersion: 6, }) }) @@ -143,11 +143,35 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 8, boosts: {} }, + state: { ...sample, version: 9, boosts: {} }, fromVersion: 7, }) }) + it('migrates a version 8 sample, keeping its safes', () => { + const sample = { + version: 8, + clock: { trackOnOpen: false }, + properties: { nightclub: { owned: true, upgrades: {} } }, + businesses: {}, + businessOrder: [], + nightclub: {}, + safes: { nightclub: { money: 50_000, sinceMs: 600_000, popularity: 85 } }, + boosts: {}, + } + expect(parseState(JSON.stringify(sample))).toEqual({ + ok: true, + state: { ...sample, version: 9 }, + fromVersion: 8, + }) + }) + + it('keeps saved Money Fronts heat', () => { + const safes = { 'car-wash': { money: 9_000, sinceMs: 0, heat: { 'car-wash': 30 } } } + const text = JSON.stringify({ ...defaultState(), safes }) + expect(parseState(text)).toMatchObject({ ok: true, state: { safes } }) + }) + it('keeps saved boosts', () => { const boosts = { 'acid-lab': { startedAt: 1_000, unitsLeft: 42.5 } } const text = JSON.stringify({ ...defaultState(), boosts }) diff --git a/src/lib/state.ts b/src/lib/state.ts index 2d58446..9a7f704 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -12,7 +12,7 @@ import { parseOwnedProperties, type OwnedProperties } from './properties' import { parseSafes, type SafesById } from './safes' /** Bump when the stored shape changes, and add a migration from the previous version. */ -export const CURRENT_VERSION = 8 +export const CURRENT_VERSION = 9 /** All user state. Trackers add their own sections here as they are built. */ export interface AppState { @@ -67,6 +67,9 @@ export const MIGRATIONS: Readonly> = { 6: (data) => ({ ...data, safes: {} }), // Version 8 adds production boosts (issue #9); none started yet. 7: (data) => ({ ...data, boosts: {} }), + // Version 9 adds heat per Money Front to the car wash safe (issue #11); nothing to change, but + // an older LSOS would drop the heat, so it must not read this data. + 8: (data) => ({ ...data }), } export type ParseResult = From 361c5567c73ee700f79328e7df7c42dc9c64aca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:13:17 +0000 Subject: [PATCH 2/3] Money Fronts: red heat at 100%, live drag value, full-in or max-heat countdown EditableBar joins EditableValue and a draggable Meter in one component, so every editable percentage bar (stock, supplies, popularity, heat) shows the value under the pointer while dragging. Heat bars turn red at 100%. The card shows "Full in" or "Car Wash max heat in", whichever comes first. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/018-money-fronts.md | 12 +++-- src/components/MoneyFrontsCard.svelte | 32 +++++--------- src/components/SafeCard.svelte | 22 +++------- src/components/ui/BusinessBars.svelte | 63 ++++++++------------------- src/components/ui/EditableBar.svelte | 60 +++++++++++++++++++++++++ src/components/ui/Meter.svelte | 15 ++++++- 6 files changed, 116 insertions(+), 88 deletions(-) create mode 100644 src/components/ui/EditableBar.svelte diff --git a/docs/decisions/018-money-fronts.md b/docs/decisions/018-money-fronts.md index b61dd7b..2d8f2dd 100644 --- a/docs/decisions/018-money-fronts.md +++ b/docs/decisions/018-money-fronts.md @@ -17,16 +17,20 @@ $100,000: $750 per front ($1,500 after 15 laundering missions) plus a fixed amount for each other business owned, from the in-game safe screen (data comment has the sources). A front at 100% heat pays nothing; the Car Wash at 100% stops the whole safe. LSOS counts every owned - business; the game skips biker businesses and the Bunker while paused or not set up, which the - card says. + business; the game skips biker businesses and the Bunker while shut down or not set up, which + the card says (shutting down: issue #38). - **Heat** per front, saved with the safe (`heat` by front id, data version 9). At the end of each day, after the payout, every front below 100% gains 10%, one less per other front below 100% (8% with all three). A laundering mission or Mr. Faber job adds 10% to the front it was launched from; a laundering mission counts toward that front's 15, a Faber job toward all three. Legal work clears the front's heat. The rise comes from a player's notes, and which fronts rise is LSOS's reading of them, so heat is marked "(est.)" (decision 013). -- **"Full in"** assumes heat stays where it is now, like popularity on the other safes. A - separate "Car Wash 100% heat in (est.)" countdown says when the safe would stop. +- **One countdown, whichever comes first:** "Full in", assuming heat stays where it is now + (like popularity on the other safes), or "Car Wash max heat in (est.)" when the Car Wash would + reach 100% and stop the safe sooner. +- **Heat bars** are amber, red at 100% like the in-game bar (Meter `heat` kind). +- **Dragging a bar** shows the value under the pointer while dragging, on every editable + percentage bar (stock, supplies, popularity, heat): one `ui/EditableBar` component. - **Edit mode** adds the resync controls (decision 010: heat values and bars, safe money with Collected/Full, "Payout just arrived" with the half-day rule of decision 016) and per front "Laundering +10%", "Faber job +10%" and "Legal job" buttons. diff --git a/src/components/MoneyFrontsCard.svelte b/src/components/MoneyFrontsCard.svelte index 8c7b4d8..900e2a3 100644 --- a/src/components/MoneyFrontsCard.svelte +++ b/src/components/MoneyFrontsCard.svelte @@ -1,6 +1,6 @@ {#if popularity !== undefined} {#if editing} - ({ @@ -71,17 +69,7 @@ })), ]} onresync={(value) => onresync({ ...current, popularity: value })} - > - (dragging = value)} - onset={(value) => onresync({ ...current, popularity: value })} - /> - + /> {:else} {/if} diff --git a/src/components/ui/BusinessBars.svelte b/src/components/ui/BusinessBars.svelte index 6447929..a344cff 100644 --- a/src/components/ui/BusinessBars.svelte +++ b/src/components/ui/BusinessBars.svelte @@ -12,8 +12,7 @@ } from '../../lib/resync' import Button from './Button.svelte' import Countdown from './Countdown.svelte' - import EditableValue from './EditableValue.svelte' - import Meter from './Meter.svelte' + import EditableBar from './EditableBar.svelte' interface Props { /** Stock and supplies in percent; leave supplies out for a business with only a stock bar. */ @@ -42,11 +41,6 @@ // One undo for the whole business: the last edit, Full or event, whichever bars it changed. // Dragging and nudging one bar with the keys in a row count as one edit. let last = $state(null) - // The level under the pointer while a bar is dragged, shown as its value. - let dragging = $state<{ stock: number | null; supplies: number | null }>({ - stock: null, - supplies: null, - }) function set(after: Bars, label: string, group?: string) { if ( @@ -71,56 +65,35 @@
- set({ ...bars, stock }, `stock was ${formatValue(bars.stock, 'percent')}`)} - > - (dragging.stock = value)} - onset={(stock) => - set({ ...bars, stock }, `stock was ${formatValue(bars.stock, 'percent')}`, 'stock')} - /> - + onset={(stock) => + set({ ...bars, stock }, `stock was ${formatValue(bars.stock, 'percent')}`, 'stock')} + /> {#if bars.supplies !== undefined} - set({ ...bars, supplies }, `supplies was ${formatValue(bars.supplies ?? 0, 'percent')}`)} + onset={(supplies) => + set( + { ...bars, supplies }, + `supplies was ${formatValue(bars.supplies ?? 0, 'percent')}`, + 'supplies', + )} > - (dragging.supplies = value)} - onset={(supplies) => - set( - { ...bars, supplies }, - `supplies was ${formatValue(bars.supplies ?? 0, 'percent')}`, - 'supplies', - )} - /> {#if dueAt !== undefined} {/if} - + {/if}
diff --git a/src/components/ui/EditableBar.svelte b/src/components/ui/EditableBar.svelte new file mode 100644 index 0000000..f6e107c --- /dev/null +++ b/src/components/ui/EditableBar.svelte @@ -0,0 +1,60 @@ + + + + (dragging = next)} + {onset} + /> + {@render children?.()} + diff --git a/src/components/ui/Meter.svelte b/src/components/ui/Meter.svelte index 4c91f37..20754c5 100644 --- a/src/components/ui/Meter.svelte +++ b/src/components/ui/Meter.svelte @@ -5,7 +5,10 @@ label: string value: number max: number - /** stock: green, supplies: blue, as on the in-game business screens; heat: amber. */ + /** + * stock: green, supplies: blue, as on the in-game business screens; heat: amber, red when + * full, like the in-game heat bar. + */ kind?: 'stock' | 'supplies' | 'heat' /** Text shown next to the label, e.g. "40 / 100" or "$120,000". Defaults to a percentage. */ valueText?: string @@ -83,7 +86,7 @@ } -
+
= max}> {#if !bare}
{label} @@ -211,6 +214,14 @@ background: var(--warn); } + .heat.maxed .value { + color: var(--danger); + } + + .heat.maxed .fill { + background: var(--danger); + } + .segments { display: flex; gap: 4px; From 7464e0996abd02c8ab656accead359a58c176a6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:18:24 +0000 Subject: [PATCH 3/3] Docs: point CLAUDE.md at EditableBar Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2bc3193..766e44d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Saved user state, migrations, export/import | `src/lib/state.ts`, `src/lib/store.svelte.ts` | | UI components (thin) | `src/components/` | | Base UI kit (panel, meter, button...), preview | `src/components/ui/`, `#/kit` in dev (dec. 009) | -| Resync values; business bars (Sold, Raided) | `ui/EditableValue`, `ui/BusinessBars` (dec. 010) | +| Resync values, editable bars (dec. 010, 018) | `ui/EditableValue`, `EditableBar`, `BusinessBars` | | Game data with sources | `src/data/` (decision 004) | | Owned properties and upgrades | `data/properties.ts`, `lib/properties.ts` (011) | | Business production (stock, supplies, value) | `data/production.ts`, `lib/production.ts` (012) |