= max}>
{#if !bare}
{label}
@@ -178,6 +181,10 @@
color: var(--supplies-text);
}
+ .heat .value {
+ color: var(--warn);
+ }
+
.track {
background: var(--track);
border-radius: var(--radius);
@@ -203,6 +210,18 @@
background: var(--supplies);
}
+ .heat .fill {
+ background: var(--warn);
+ }
+
+ .heat.maxed .value {
+ color: var(--danger);
+ }
+
+ .heat.maxed .fill {
+ background: var(--danger);
+ }
+
.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 =