diff --git a/CLAUDE.md b/CLAUDE.md index 54c69d2..5fd2f84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Value bonus for owning a front | `ownedBonus` in `data/production.ts` (019) | | Shut-down businesses (owned, not running) | `isRunning` in `lib/properties.ts` (020) | | Real-time timers: Lucky Wheel, Mansion boost | `data/timers.ts`, `lib/timers.ts` (022) | +| Overview tab, next events, notifications | `lib/events.ts`, `lib/overview.ts` (dec. 023) | | Theme tokens | `src/app.css` (decision 009) | | Link previews (Open Graph), preview image | `index.html`, `scripts/og-image.html` | | CI, templates | `.github/` | diff --git a/README.md b/README.md index afe7f8e..59e86a5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ emptying. run on the wall clock even when LSOS is closed. - **Sourced game data.** Rates and values come from sources such as gta.wiki. Where none exists for your upgrades, LSOS uses a best estimate and labels those numbers "(est.)". +- **Overview for a second screen.** One page lists what is ready now and what comes next across + every tracker, soonest first, with the money waiting. Mark things done right there (sold, + collected, spun), and get optional alerts as pop-ups or browser notifications. - **Resync anytime.** When an estimate drifts from what the game shows, click the value and type the real one. - **Weekly events** (bonuses, discounts) come from a data file in this repo that's updated weekly. diff --git a/docs/decisions/023-overview.md b/docs/decisions/023-overview.md new file mode 100644 index 0000000..3de00c8 --- /dev/null +++ b/docs/decisions/023-overview.md @@ -0,0 +1,56 @@ +# 023: Overview tab: every tracker's next event in one list, made for a second screen + +2026-10-15, issue #15. Builds on decisions 003, 010, 012, 013, 015, 016, 017, 018 and 022. + +## Decision + +- **Shared shape:** each tracker gives its next events as `NextEvent` (`src/lib/events.ts`): what + happens, where, the time left and on which clock (play or real), whether it is good or bad news + once due, the action to take, the bar that moves toward it, the money waiting, whether it rests + on an estimate, the card it belongs to (`tab` and `focus`) and, for timers that need nothing + else, a quick action. The trackers build them: `productionEvents`, `goodsEvents`, `safeEvents`, + `frontsEvents`, `timerEvents`. `src/lib/overview.ts` gathers and sorts them. +- **Events:** per running business with a known rate, whichever comes first: stock full (Sell) or + supplies out (Resupply). The Acid Lab boost and the Mansion boost when they can be started; the + Lucky Wheel spin; each nightclub goods type a technician makes, when full; each owned safe, + when full; the Money Fronts safe, when full, and the Car Wash at max heat, as the safe card + shows them. +- **Supplies on their way** are no event of their own: they need nothing from the player. The + forecast (`forecastAt`, also on the business cards) runs the business on what it has until + they arrive, then on full supplies. If the stock fills before they arrive, that comes first. + Play time is taken to run along with real time meanwhile, as `produceAll` does. +- **Order:** due items first, bad news (something stopped) before good news, then the rest by + time left. Play and real time are compared directly: they run at the same pace while playing. +- **Layout, made to stay open on a 1080p second screen without scrolling:** a strip with the + number of items ready, the money waiting (business stock at the value the cards show, nightclub + goods, safes, total) and nightclub popularity with its bar, amber when it cuts the payout; + "Ready now" as large cards with the action first; "Coming up" as one line per item (time, + name, bar, value) in two columns. While play time is paused, play-time countdowns turn grey. +- **Jump to the card:** every item links to `#//`. The tab opens, the card with that + `data-focus` (a `Panel` prop) scrolls into view and glows for a moment, and the address drops + back to `#/` so the same link works again. +- **Quick actions:** every Ready now card has a button to mark it done right there: Sold (stock + or nightclub goods, as the Sold event on the card), Bought (supplies, a delivery as on the + card), Collected (a safe), Done (a legal job clears the Car Wash heat), Spun (Lucky Wheel), + Started (a production boost; the Mansion boost picks its business in a dropdown first). Undo + stays offered for 15 seconds and puts back only what the action took, on top of progress + since: stock or money made meanwhile is kept. Partial sales and corrections stay on the cards + (decision 010). +- **Alerts** (Settings, all off until opted in): pop-ups inside LSOS on any tab, and browser + notifications (permission asked when ticked). Both come a chosen time before an item is due + (when it happens, or 1, 5, 10, 15 or 30 minutes before; default 10), once per item, and + optionally only while LSOS is not the focused window. A pop-up shows the live countdown, links + to the card, and closes when dismissed or once the item is handled. Items already that close + when LSOS opens, when the alert time changes, or after an undo, do not alert. +- **Saved state** (version 12): `alerts: { browser, popup, leadMs, unfocusedOnly }`. + +## Why + +A player glancing at a second screen wants three answers: what to do now, what comes next and +how much money is waiting. Actions as verbs ("Sell", "Resupply", "Collect") answer the first +without reading further. A supply delivery is not something to do, so it must not hide what +comes next. Nightclub popularity is rarely checked in game, yet it cuts the safe's payout, so it +gets a place in the strip rather than a list item: as an event it would almost always be due, +since it falls below the top payout within a day of being maxed. The shape came from the +trackers that exist; each one fills it from its own logic, so the Overview's numbers are the +cards' numbers. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 04ca7b2..3c6ec83 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -33,3 +33,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [020](020-shut-down.md) Shut-down businesses: a Running/Shut down button next to Owned for biker businesses and the Bunker; shutting down clears the stock; no production, card greyed, nightclub goods locked, nothing to the Money Fronts safe - [021](021-no-heist-tracker.md) No heist cooldown tracker: only Cayo Perico has one worth a timer; Heists tab removed - [022](022-real-time-timers.md) Timers tab on real time: Lucky Wheel (24 h) and Mansion production boost (×3 for 24 h, 24 h cooldown, not the same business twice in a row; with the Acid Lab boost: est.) +- [023](023-overview.md) Overview tab: trackers give a shared `NextEvent`, due first then soonest, supplies on their way counted in the forecast, links that scroll to the card, quick actions with undo on every ready item, popularity in view; alerts as pop-ups and/or notifications, set time ahead diff --git a/src/App.svelte b/src/App.svelte index 571cc87..edad11d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,7 @@
@@ -48,6 +72,15 @@ {#await kitModule then { default: Kit }} {/await} + {:else if active === 'overview'} + {:else if active === 'businesses'}
+ + diff --git a/src/components/Popups.svelte b/src/components/Popups.svelte new file mode 100644 index 0000000..e8dc2fe --- /dev/null +++ b/src/components/Popups.svelte @@ -0,0 +1,131 @@ + + +{#if shown.length > 0} +
+ {#each shown as event (event.id)} + + {/each} +
+{/if} + + diff --git a/src/components/Safes.svelte b/src/components/Safes.svelte index 9db9b68..7459613 100644 --- a/src/components/Safes.svelte +++ b/src/components/Safes.svelte @@ -50,7 +50,7 @@ {:else}
{#if fronts} - + {#if open === CAR_WASH} {#each frontProperties as property (property.id)} @@ -79,7 +79,7 @@ {/if} {#each shown as { safe, property } (property.id)} - + {#if open === property.id} {/if} diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index 3552584..9f551eb 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -1,13 +1,30 @@ -
+

{#if move}
(isDue(e) ? (e.tone === 'bad' ? 0 : 1) : 2) + return [...events].sort((a, b) => rank(a) - rank(b) || Math.max(a.inMs, 0) - Math.max(b.inMs, 0)) +} + +/** Whether an alert `leadMs` ahead is due for the event: it is that close or due. */ +export function isNear(event: NextEvent, leadMs: number): boolean { + return event.inMs <= leadMs +} + +/** The ids of the events within `leadMs` of being due, or due. */ +export function nearIds(events: readonly NextEvent[], leadMs: number): Set { + return new Set(events.filter((e) => isNear(e, leadMs)).map((e) => e.id)) +} + +/** The events that came within `leadMs` since `before` (ids, from `nearIds`): one alert each. */ +export function newlyNear( + before: ReadonlySet, + events: readonly NextEvent[], + leadMs: number, +): NextEvent[] { + return events.filter((e) => isNear(e, leadMs) && !before.has(e.id)) +} diff --git a/src/lib/fronts.ts b/src/lib/fronts.ts index e1fd868..25033df 100644 --- a/src/lib/fronts.ts +++ b/src/lib/fronts.ts @@ -15,6 +15,7 @@ import { type Front, } from '../data/fronts' import type { Property } from '../data/properties' +import type { NextEvent } from './events' import { isOwned, isRunning, setUpgrade, upgradeValue, type OwnedProperties } from './properties' import { DAY_MS, nextPayoutInMs, type SafeState } from './safes' @@ -201,3 +202,52 @@ export function legalJob(state: SafeState, frontId: string): SafeState { export function setHeat(state: SafeState, frontId: string, heat: number): SafeState { return { ...state, heat: { ...state.heat, [frontId]: heat } } } + +/** + * When the Money Fronts safe is full, and when the Car Wash reaches 100% heat and stops paying + * into it (issue #15), as the safe card shows them. + */ +export function frontsEvents( + state: SafeState, + owned: OwnedProperties, + catalog: readonly Property[], +): NextEvent[] { + if (ownedFronts(owned).length === 0) return [] + const events: NextEvent[] = [] + const full = fullInMs(state, owned, catalog) + if (full !== null) { + events.push({ + id: `safe:${CAR_WASH}`, + source: 'Money Fronts', + what: 'Safe full', + action: 'Collect', + inMs: full, + clock: 'play', + tone: 'good', + level: { value: state.money, max: FRONTS_CAPACITY, kind: 'stock' }, + value: state.money, + estimated: false, + tab: 'safes', + focus: CAR_WASH, + quick: { label: 'Collected' }, + }) + } + const heat = maxHeatInMs(state, owned, CAR_WASH) + if (heat !== null) { + events.push({ + id: `heat:${CAR_WASH}`, + source: FRONTS.find((f) => f.propertyId === CAR_WASH)!.label, + what: 'Max heat', + action: 'Legal job', + inMs: heat, + clock: 'play', + tone: 'bad', + level: { value: heatOf(state, CAR_WASH), max: MAX_HEAT, kind: 'heat' }, + estimated: true, + tab: 'safes', + focus: CAR_WASH, + quick: { label: 'Done' }, + }) + } + return events +} diff --git a/src/lib/nightclub.ts b/src/lib/nightclub.ts index 00518e5..5544633 100644 --- a/src/lib/nightclub.ts +++ b/src/lib/nightclub.ts @@ -2,6 +2,7 @@ // Pure logic: play time comes in as an argument (decision 003). import { MAX_TECHNICIANS, NIGHTCLUB_ID, type Goods } from '../data/nightclub' import type { Property } from '../data/properties' +import type { NextEvent } from './events' import { isOwned, isRunning, upgradeValue, type OwnedProperties } from './properties' const MINUTE_MS = 60_000 @@ -134,6 +135,41 @@ export function produceGoods( return next } +/** + * When each goods type a technician is making is full (issue #15): the technician then makes + * nothing until it is sold. + */ +export function goodsEvents( + stock: GoodsStock, + catalog: readonly Goods[], + properties: readonly Property[], + owned: OwnedProperties, +): NextEvent[] { + const nightclub = properties.find((p) => p.id === NIGHTCLUB_ID) + if (!nightclub || !isOwned(owned, nightclub.id)) return [] + const setup = warehouseSetup(owned, nightclub) + return catalog + .filter((goods) => isProducing(goods, setup, owned)) + .map((goods) => { + const crates = stock[goods.id] ?? 0 + return { + id: `goods:${goods.id}`, + source: nightclub.label, + what: `${goods.label} full`, + action: 'Sell', + inMs: fullInMs(goods, crates, setup), + clock: 'play', + tone: 'good', + level: { value: crates, max: capacity(goods, setup.floors), kind: 'stock' }, + value: goodsValue(goods, crates), + estimated: false, + tab: 'businesses', + focus: nightclub.id, + quick: { label: 'Sold' }, + } + }) +} + /** * Checks saved stock. Entries are kept for any id, like owned properties (decision 011). * Returns an error message if the shape is wrong. diff --git a/src/lib/overview.svelte.ts b/src/lib/overview.svelte.ts new file mode 100644 index 0000000..0bc5f95 --- /dev/null +++ b/src/lib/overview.svelte.ts @@ -0,0 +1,119 @@ +// The Overview's events, holdings and popularity on the live state, its quick actions, and the +// alerts when an item comes due: browser notifications and pop-ups (issue #15). Logic lives in +// overview.ts and events.ts; this file only wires it to the clock, the store and the browser. +import { untrack } from 'svelte' +import { SUPPLY_PURCHASE } from '../data/business-events' +import { GOODS } from '../data/nightclub' +import { PRODUCTION } from '../data/production' +import { PROPERTIES } from '../data/properties' +import { SAFES } from '../data/safes' +import { LUCKY_WHEEL, MANSION_BOOST } from '../data/timers' +import { clocks } from './clock.svelte' +import { isDue, nearIds, newlyNear, type NextEvent } from './events' +import { + allEvents, + clubPopularity, + holdings, + quickAction, + type QuickResult, + type TrackerData, +} from './overview' +import { store } from './store.svelte' + +const DATA: TrackerData = { + catalog: PROPERTIES, + productions: PRODUCTION, + goods: GOODS, + safes: SAFES, + wheel: LUCKY_WHEEL, + mansion: MANSION_BOOST, + fillsTo: SUPPLY_PURCHASE.fillsTo, + deliveryMs: SUPPLY_PURCHASE.deliveryMs, +} + +class Overview { + events = $derived(allEvents(store.state, DATA, clocks.now)) + holdings = $derived(holdings(store.state, DATA, clocks.now)) + popularity = $derived(clubPopularity(store.state, DATA)) + /** Ids of the events with a pop-up open, oldest first. */ + popups: string[] = $state([]) +} + +export const overview = new Overview() + +/** Marks an event done from the Overview (its `quick`). Returns what undoes it, if done. */ +export function quick(event: NextEvent, choice?: string): QuickResult | null { + const result = quickAction(store.state, DATA, event.id, Date.now(), choice) + if (result) apply(result.change) + return result +} + +/** Takes back a quick action on event `id`: it is ready again, but that is no news to alert. */ +export function undoQuick(id: string, undo: QuickResult['undo']): void { + Object.assign(store.state, undo(store.state)) + seen?.add(id) +} + +function apply(change: QuickResult['change']): void { + Object.assign(store.state, change) +} + +export function dismissPopup(id: string): void { + overview.popups = overview.popups.filter((p) => p !== id) +} + +/** Whether this browser can show notifications at all. */ +export function notificationsSupported(): boolean { + return typeof Notification !== 'undefined' +} + +/** + * Turns browser notifications on after asking the browser for permission, or off. Returns why + * they could not be turned on, if they could not. + */ +export async function setBrowserAlerts(on: boolean): Promise { + if (on) { + if (!notificationsSupported()) return 'This browser cannot show notifications.' + const permission = await Notification.requestPermission() + if (permission !== 'granted') { + return 'The browser blocked notifications for LSOS. Allow them in its site settings, then try again.' + } + } + store.state.alerts = { ...store.state.alerts, browser: on } + return null +} + +function notify(event: NextEvent): void { + const body = isDue(event) + ? event.action && `${event.action} now.` + : `In ${Math.ceil(event.inMs / 60_000)} min.` + new Notification(`${event.source}: ${event.what}`, { body: body || undefined, tag: event.id }) +} + +// What was already near when LSOS opened, or when the alert time changed, alerts no one. +let seen: Set | null = null +let seenLead = -1 + +$effect.root(() => { + $effect(() => { + const events = overview.events + const { browser, popup, leadMs, unfocusedOnly } = store.state.alerts + const near = nearIds(events, leadMs) + if (seen && seenLead === leadMs && !(unfocusedOnly && document.hasFocus())) { + const fresh = newlyNear(seen, events, leadMs) + if (browser && notificationsSupported() && Notification.permission === 'granted') { + fresh.forEach(notify) + } + if (popup && fresh.length > 0) { + untrack(() => (overview.popups = [...overview.popups, ...fresh.map((e) => e.id)])) + } + } + seen = near + seenLead = leadMs + // A pop-up closes on its own once its item is handled (sold, collected, spun). + untrack(() => { + const open = overview.popups.filter((id) => near.has(id)) + if (open.length !== overview.popups.length) overview.popups = open + }) + }) +}) diff --git a/src/lib/overview.test.ts b/src/lib/overview.test.ts new file mode 100644 index 0000000..1ca9499 --- /dev/null +++ b/src/lib/overview.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from 'vitest' +import { SUPPLY_PURCHASE } from '../data/business-events' +import { GOODS } from '../data/nightclub' +import { PRODUCTION } from '../data/production' +import { PROPERTIES } from '../data/properties' +import { SAFES } from '../data/safes' +import { LUCKY_WHEEL, MANSION_BOOST } from '../data/timers' +import { nearIds, newlyNear, sortEvents, type NextEvent } from './events' +import { allEvents, clubPopularity, holdings, quickAction, type TrackerData } from './overview' +import type { OwnedProperties } from './properties' +import { DAY_MS } from './safes' +import { defaultState, type AppState } from './state' + +const MIN = 60_000 +const HOUR = 60 * MIN +const NOW = Date.UTC(2026, 9, 15, 12) + +const DATA: TrackerData = { + catalog: PROPERTIES, + productions: PRODUCTION, + goods: GOODS, + safes: SAFES, + wheel: LUCKY_WHEEL, + mansion: MANSION_BOOST, + fillsTo: SUPPLY_PURCHASE.fillsTo, + deliveryMs: SUPPLY_PURCHASE.deliveryMs, +} + +function owning(upgrades: Record>) { + const owned: OwnedProperties = {} + for (const [id, u] of Object.entries(upgrades)) owned[id] = { owned: true, upgrades: u } + return owned +} + +function state(changes: Partial): AppState { + return { ...defaultState(), ...changes } +} + +const find = (events: NextEvent[], id: string) => events.find((e) => e.id === id) + +describe('allEvents', () => { + it('always has the Lucky Wheel, ready until spun', () => { + expect(allEvents(defaultState(), DATA, NOW)).toEqual([ + expect.objectContaining({ id: 'lucky-wheel', inMs: 0, clock: 'real', tab: 'timers' }), + ]) + const spun = state({ timers: { luckyWheelSpunAt: NOW - 23 * HOUR } }) + expect(find(allEvents(spun, DATA, NOW), 'lucky-wheel')?.inMs).toBe(HOUR) + }) + + it('gives a business stock full when that comes first, with its value', () => { + // Bunker with both upgrades: 1% stock per 7 minutes; supplies last 20 units. + const s = state({ + properties: owning({ bunker: { equipment: true, staff: true } }), + businesses: { bunker: { stock: 90, supplies: 100 } }, + }) + expect(find(allEvents(s, DATA, NOW), 'stock:bunker')).toEqual({ + id: 'stock:bunker', + source: 'Bunker', + what: 'Stock full', + action: 'Sell', + inMs: 70 * MIN, + clock: 'play', + tone: 'good', + level: { value: 90, max: 100, kind: 'stock' }, + // 90 units at $7,000, sold in Los Santos for 50% more. + value: 945_000, + estimated: false, + tab: 'businesses', + focus: 'bunker', + quick: { label: 'Sold' }, + }) + }) + + it('gives supplies out when that comes first', () => { + const owned = owning({ bunker: { equipment: true, staff: true } }) + const s = state({ properties: owned, businesses: { bunker: { stock: 50, supplies: 100 } } }) + expect(find(allEvents(s, DATA, NOW), 'supplies:bunker')).toMatchObject({ + inMs: 140 * MIN, + tone: 'bad', + action: 'Resupply', + }) + }) + + describe('with supplies on their way', () => { + const owned = owning({ bunker: { equipment: true, staff: true } }) + const at = (stock: number, supplies: number) => + allEvents( + state({ + properties: owned, + businesses: { bunker: { stock, supplies, suppliesDueAt: NOW + 4 * MIN } }, + }), + DATA, + NOW, + ) + + it('counts them from their arrival', () => { + // Empty: nothing is made for 4 minutes, then full supplies last 140 minutes. + expect(find(at(50, 0), 'supplies:bunker')?.inMs).toBe(144 * MIN) + // Supplies for 2 more units: they keep running until the delivery tops them up. + expect(find(at(50, 10), 'supplies:bunker')?.inMs).toBeCloseTo(144 * MIN) + }) + + it('gives stock full if it fills before or after they arrive', () => { + expect(find(at(99, 100), 'stock:bunker')?.inMs).toBeCloseTo(7 * MIN) + // No supplies: the last unit starts once they arrive. + expect(find(at(99, 0), 'stock:bunker')?.inMs).toBeCloseTo(11 * MIN) + }) + + it('shows no countdown of its own', () => { + expect(at(50, 0).map((e) => e.id)).toEqual(['lucky-wheel', 'supplies:bunker']) + }) + }) + + it('counts the Mansion boost and offers it again after its cooldown', () => { + const owned = owning({ bunker: { equipment: true, staff: true }, mansion: {} }) + const s = state({ + properties: owned, + businesses: { bunker: { stock: 50, supplies: 100 } }, + timers: { mansionBoost: { propertyId: 'bunker', startedAt: NOW - 23 * HOUR } }, + }) + const events = allEvents(s, DATA, NOW) + // Three times as fast: 20 units in 140 / 3 minutes. + expect(find(events, 'supplies:bunker')?.inMs).toBeCloseTo((140 * MIN) / 3) + // Only the Bunker can be boosted, and not twice in a row: nothing to start. + expect(find(events, 'mansion-boost')).toBeUndefined() + const two = state({ ...s, properties: { ...owned, ...owning({ 'mc-weed': {} }) } }) + expect(find(allEvents(two, DATA, NOW), 'mansion-boost')?.inMs).toBe(HOUR) + }) + + it('gives the Acid Lab boost on real time, not while it runs', () => { + const s = state({ properties: owning({ 'acid-lab': { equipment: true } }) }) + expect(find(allEvents(s, DATA, NOW), 'boost:acid-lab')).toMatchObject({ inMs: 0 }) + const started = state({ ...s, boosts: { 'acid-lab': { startedAt: NOW, unitsLeft: 80 } } }) + expect(find(allEvents(started, DATA, NOW), 'boost:acid-lab')).toBeUndefined() + // All 80 units made: the next one comes at the 06:00 UTC reset. + const done = state({ ...s, boosts: { 'acid-lab': { startedAt: NOW, unitsLeft: 0 } } }) + expect(find(allEvents(done, DATA, NOW), 'boost:acid-lab')?.inMs).toBe(18 * HOUR) + }) + + it('leaves out shut-down businesses', () => { + const owned = owning({ 'mc-weed': {} }) + owned['mc-weed']!.shutDown = true + const ids = allEvents(state({ properties: owned }), DATA, NOW).map((e) => e.id) + expect(ids).toEqual(['lucky-wheel']) + }) + + it('gives nightclub goods full per technician', () => { + // One floor: 8 crates of Cash Creation, one per 60 minutes. + const s = state({ + properties: owning({ nightclub: { 'technician-1': 'cash' }, 'mc-cash': {} }), + nightclub: { cash: 6 }, + }) + expect(find(allEvents(s, DATA, NOW), 'goods:cash')).toMatchObject({ + source: 'Nightclub', + what: 'Cash Creation full', + inMs: 120 * MIN, + level: { value: 6, max: 8, kind: 'stock' }, + value: 28_350, + }) + }) + + it('gives safes full, and the Car Wash at max heat', () => { + const s = state({ + properties: owning({ nightclub: {}, 'car-wash': {} }), + safes: { + nightclub: { money: 120_000, sinceMs: 0, popularity: 100 }, + // Alone, heat rises 10% a day: 75% reaches 100% at the third payout. + 'car-wash': { money: 0, sinceMs: 8 * MIN, heat: { 'car-wash': 75 } }, + }, + }) + const events = allEvents(s, DATA, NOW) + expect(find(events, 'safe:nightclub')).toMatchObject({ inMs: 3 * DAY_MS, value: 120_000 }) + expect(find(events, 'heat:car-wash')).toMatchObject({ + inMs: 40 * MIN + 2 * DAY_MS, + tone: 'bad', + estimated: true, + }) + expect(find(events, 'safe:car-wash')).toMatchObject({ source: 'Money Fronts' }) + }) +}) + +describe('sortEvents', () => { + const event = (id: string, inMs: number, tone: 'good' | 'bad' = 'good'): NextEvent => ({ + id, + source: id, + what: id, + inMs, + clock: 'play', + tone, + estimated: false, + tab: 'overview', + focus: id, + }) + + it('puts due bad news first, then due good news, then the soonest', () => { + const events = [event('later', 5), event('ready', 0), event('soon', 1), event('out', 0, 'bad')] + expect(sortEvents(events).map((e) => e.id)).toEqual(['out', 'ready', 'soon', 'later']) + }) + + it('finds what came within the alert time since the last look', () => { + const before = nearIds([event('a', 0), event('b', 3 * MIN), event('c', 20 * MIN)], 0) + const now = [event('a', 0), event('b', 0), event('c', 9 * MIN), event('d', 11 * MIN)] + expect(newlyNear(before, now, 0).map((e) => e.id)).toEqual(['b']) + // Alerting 10 minutes ahead: c is that close now. + expect(newlyNear(nearIds(now, 10 * MIN), now, 10 * MIN)).toEqual([]) + expect(newlyNear(before, now, 10 * MIN).map((e) => e.id)).toEqual(['b', 'c']) + }) +}) + +describe('holdings', () => { + it('adds up stock, goods and safes', () => { + const s = state({ + properties: owning({ + bunker: { equipment: true, staff: true }, + nightclub: {}, + 'car-wash': {}, + }), + businesses: { bunker: { stock: 10, supplies: 0 } }, + nightclub: { cash: 2.5, cargo: 1 }, + safes: { + nightclub: { money: 40_000, sinceMs: 0, popularity: 100 }, + 'car-wash': { money: 5_000, sinceMs: 0 }, + }, + }) + expect(holdings(s, DATA, NOW)).toEqual({ + stock: 105_000, + goods: 2 * 4_725 + 10_000, + safes: 45_000, + estimated: false, + }) + }) +}) + +describe('quickAction', () => { + it('spins the Lucky Wheel; undo puts back the last spin', () => { + const s = state({ timers: { luckyWheelSpunAt: 1 } }) + const result = quickAction(s, DATA, 'lucky-wheel', NOW)! + expect(result.change).toEqual({ timers: { luckyWheelSpunAt: NOW } }) + expect(result.done).toBe('Lucky Wheel spun') + expect(result.undo(state({ timers: { luckyWheelSpunAt: NOW } }))).toEqual({ + timers: { luckyWheelSpunAt: 1 }, + }) + }) + + it('starts the Mansion boost on a business it can take', () => { + const owned = owning({ mansion: {}, bunker: {}, 'mc-weed': {} }) + const last = { propertyId: 'bunker', startedAt: NOW - 25 * HOUR } + const s = state({ properties: owned, timers: { mansionBoost: last } }) + const event = find(allEvents(s, DATA, NOW), 'mansion-boost') + expect(event?.quick?.choices).toEqual([{ id: 'mc-weed', label: 'Weed Farm' }]) + const result = quickAction(s, DATA, 'mansion-boost', NOW, 'mc-weed')! + expect(result.change).toEqual({ + timers: { mansionBoost: { propertyId: 'mc-weed', startedAt: NOW } }, + }) + expect(result.undo(state({ ...s, ...result.change }))).toEqual({ + timers: { mansionBoost: last }, + }) + // Not the same business twice in a row, and not without a pick. + expect(quickAction(s, DATA, 'mansion-boost', NOW, 'bunker')).toBeNull() + expect(quickAction(s, DATA, 'mansion-boost', NOW)).toBeNull() + }) + + it('starts a production boost; undo removes it', () => { + const s = state({ properties: owning({ 'acid-lab': { equipment: true } }) }) + const result = quickAction(s, DATA, 'boost:acid-lab', NOW)! + expect(result.change).toEqual({ boosts: { 'acid-lab': { startedAt: NOW, unitsLeft: 80 } } }) + expect(result.done).toBe('Acid Lab boost started') + expect(result.undo(state({ ...s, ...result.change }))).toEqual({ boosts: {} }) + }) + + it('sells the stock; undo adds it back on top of what was made since', () => { + const s = state({ businesses: { bunker: { stock: 100, supplies: 40 } } }) + const result = quickAction(s, DATA, 'stock:bunker', NOW)! + expect(result.change).toEqual({ businesses: { bunker: { stock: 0, supplies: 40 } } }) + expect(result.done).toBe('Bunker: stock sold') + const later = state({ businesses: { bunker: { stock: 2, supplies: 30 } } }) + expect(result.undo(later)).toEqual({ businesses: { bunker: { stock: 100, supplies: 30 } } }) + }) + + it('buys supplies; undo cancels the delivery', () => { + const s = state({ businesses: { bunker: { stock: 50, supplies: 0 } } }) + const result = quickAction(s, DATA, 'supplies:bunker', NOW)! + expect(result.change).toEqual({ + businesses: { bunker: { stock: 50, supplies: 0, suppliesDueAt: NOW + 10 * MIN } }, + }) + expect(result.undo(state({ ...s, ...result.change }))).toEqual({ + businesses: { bunker: { stock: 50, supplies: 0 } }, + }) + }) + + it('collects a safe; undo adds the money back to any payout since', () => { + const s = state({ safes: { agency: { money: 250_000, sinceMs: 30 * MIN } } }) + const result = quickAction(s, DATA, 'safe:agency', NOW)! + expect(result.change).toEqual({ safes: { agency: { money: 0, sinceMs: 30 * MIN } } }) + expect(result.done).toBe('Agency safe collected') + const paid = state({ safes: { agency: { money: 1_250, sinceMs: 2 * MIN } } }) + expect(result.undo(paid)).toEqual({ safes: { agency: { money: 251_250, sinceMs: 2 * MIN } } }) + }) + + it('collects the Money Fronts safe and clears the Car Wash heat', () => { + const fronts = { money: 100_000, sinceMs: 0, heat: { 'car-wash': 100 } } + const s = state({ safes: { 'car-wash': fronts } }) + expect(quickAction(s, DATA, 'safe:car-wash', NOW)?.change).toEqual({ + safes: { 'car-wash': { ...fronts, money: 0 } }, + }) + const job = quickAction(s, DATA, 'heat:car-wash', NOW)! + expect(job.change).toEqual({ safes: { 'car-wash': { ...fronts, heat: { 'car-wash': 0 } } } }) + expect(job.undo(state({ ...s, ...job.change }))).toEqual({ safes: { 'car-wash': fronts } }) + }) + + it('sells nightclub goods; undo adds them back', () => { + const s = state({ nightclub: { cash: 8, cargo: 3 } }) + const result = quickAction(s, DATA, 'goods:cash', NOW)! + expect(result.change).toEqual({ nightclub: { cash: 0, cargo: 3 } }) + expect(result.undo(state({ nightclub: { cash: 0.5, cargo: 3 } }))).toEqual({ + nightclub: { cash: 8.5, cargo: 3 }, + }) + }) + + it('has nothing for unknown events', () => { + expect(quickAction(defaultState(), DATA, 'stock:nope', NOW)).toBeNull() + expect(quickAction(defaultState(), DATA, 'boost:bunker', NOW)).toBeNull() + expect(quickAction(defaultState(), DATA, 'safe:nope', NOW)).toBeNull() + }) +}) + +describe('clubPopularity', () => { + it('gives popularity and the payout it makes, with a nightclub owned', () => { + expect(clubPopularity(defaultState(), DATA)).toBeNull() + const s = state({ + properties: owning({ nightclub: {} }), + safes: { nightclub: { money: 0, sinceMs: 0, popularity: 85 } }, + }) + expect(clubPopularity(s, DATA)).toEqual({ value: 85, payout: 25_000, best: 50_000 }) + }) +}) diff --git a/src/lib/overview.ts b/src/lib/overview.ts new file mode 100644 index 0000000..b5e3f9d --- /dev/null +++ b/src/lib/overview.ts @@ -0,0 +1,264 @@ +// The Overview tab (issue #15, decision 023): every tracker's next events in one list, and the +// money waiting across them. Pure logic: wall-clock time comes in as an argument (decision 003). +import { BUSINESS_EVENTS } from '../data/business-events' +import { CAR_WASH } from '../data/fronts' +import { NIGHTCLUB_ID, type Goods } from '../data/nightclub' +import type { Production } from '../data/production' +import type { Property } from '../data/properties' +import type { Safe } from '../data/safes' +import type { LUCKY_WHEEL } from '../data/timers' +import { sortEvents, type NextEvent } from './events' +import { emptyFronts, frontsEvents, heatOf, legalJob, ownedFronts, setHeat } from './fronts' +import { goodsEvents, goodsValue } from './nightclub' +import { + emptyBars, + estimatedParts, + farValueEstimated, + matchingRate, + productionEvents, + startBoost, + stockValue, + valueBonus, +} from './production' +import { isOwned } from './properties' +import { applyEvent, buySupplies, settleBars } from './resync' +import { bestPayout, dailyPayout, emptySafe, safeEvents } from './safes' +import type { AppState } from './state' +import { boostableBusinesses, timerEvents, type MansionBoostData } from './timers' + +/** The game data the trackers run on, passed in so tests can use their own. */ +export interface TrackerData { + catalog: readonly Property[] + productions: readonly Production[] + goods: readonly Goods[] + safes: readonly Safe[] + wheel: typeof LUCKY_WHEEL + mansion: MansionBoostData + /** Supplies after a bought delivery arrives, in percent, and how long it takes. */ + fillsTo: number + deliveryMs: number +} + +/** Every tracker's next events at `now`, due ones first (`sortEvents`). */ +export function allEvents(state: AppState, data: TrackerData, now: number): NextEvent[] { + const owned = state.properties + return sortEvents([ + ...productionEvents( + state.businesses, + data.catalog, + data.productions, + owned, + now, + data.fillsTo, + state.boosts, + { boost: state.timers.mansionBoost, data: data.mansion }, + ), + ...goodsEvents(state.nightclub, data.goods, data.catalog, owned), + ...safeEvents(state.safes, data.safes, data.catalog, owned), + ...frontsEvents(state.safes[CAR_WASH] ?? emptyFronts(), owned, data.catalog), + ...timerEvents(state.timers, data.catalog, owned, now, data.wheel, data.mansion), + ]) +} + +/** Money waiting to be collected or sold, by where it is. */ +export interface Holdings { + /** Business stock at its sale value: far where a far sale pays more, as the cards show it. */ + stock: number + /** Nightclub warehouse goods, whole crates. */ + goods: number + /** Money in the safes, the Money Fronts safe included. */ + safes: number + /** Whether the stock value rests on an estimate (decision 013). */ + estimated: boolean +} + +export function holdings(state: AppState, data: TrackerData, now: number): Holdings { + const owned = state.properties + let stock = 0 + let estimated = false + for (const production of data.productions) { + const property = data.catalog.find((p) => p.id === production.propertyId) + if (!property || !isOwned(owned, property.id)) continue + const rate = matchingRate(production, owned, property) + if (!rate) continue + const bars = settleBars(state.businesses[property.id] ?? emptyBars(), now, data.fillsTo) + const value = stockValue(bars, production, rate, valueBonus(production, owned, property)) + if (value.nearby <= 0) continue + stock += value.far ?? value.nearby + const parts = estimatedParts(rate) + if (parts.value || parts.production || farValueEstimated(production, owned)) estimated = true + } + const goods = isOwned(owned, NIGHTCLUB_ID) + ? data.goods.reduce((sum, g) => sum + goodsValue(g, state.nightclub[g.id] ?? 0), 0) + : 0 + let safes = data.safes + .filter((safe) => isOwned(owned, safe.propertyId)) + .reduce((sum, safe) => sum + (state.safes[safe.propertyId]?.money ?? 0), 0) + if (ownedFronts(owned).length > 0) safes += state.safes[CAR_WASH]?.money ?? 0 + return { stock, goods, safes, estimated } +} + +/** + * A quick action's result: what to change in the state, what was done, and how to take it back + * later. The undo puts back only what the action changed, so progress made since stays. + */ +export interface QuickResult { + change: Partial + undo: (state: AppState) => Partial + done: string +} + +/** + * Marks event `id` done from the Overview at `now` (its `quick`): stock sold, supplies bought, + * a safe collected, a legal job at the Car Wash, the Lucky Wheel spun, a production boost or the + * Mansion boost (on `choice`) started. Null if the event has none, or the choice is not one the + * boost can take. + */ +export function quickAction( + state: AppState, + data: TrackerData, + id: string, + now: number, + choice?: string, +): QuickResult | null { + const [kind, target = ''] = id.split(':') + const label = (propertyId: string) => + data.catalog.find((p) => p.id === propertyId)?.label ?? propertyId + const { timers } = state + switch (kind) { + case 'stock': + case 'supplies': { + const before = state.businesses[target] + if (!data.productions.some((p) => p.propertyId === target)) return null + const bars = settleBars(before ?? emptyBars(), now, data.fillsTo) + const after = + kind === 'stock' + ? applyEvent(bars, BUSINESS_EVENTS.sold) + : buySupplies(bars, now, data.deliveryMs) + return { + change: { businesses: { ...state.businesses, [target]: after } }, + undo: (s) => { + const now = s.businesses[target] ?? after + if (kind === 'supplies') { + const { suppliesDueAt: _, ...rest } = now + return { businesses: { ...s.businesses, [target]: rest } } + } + // The stock sold comes back on top of what was made since. + const stock = Math.min(100, bars.stock + now.stock) + return { businesses: { ...s.businesses, [target]: { ...now, stock } } } + }, + done: `${label(target)}: ${kind === 'stock' ? 'stock sold' : 'supplies bought'}`, + } + } + case 'goods': { + const goods = data.goods.find((g) => g.id === target) + if (!goods) return null + const before = state.nightclub[target] + return { + change: { nightclub: { ...state.nightclub, [target]: 0 } }, + undo: (s) => ({ + nightclub: { ...s.nightclub, [target]: (before ?? 0) + (s.nightclub[target] ?? 0) }, + }), + done: `${goods.label} sold`, + } + } + case 'safe': { + const fronts = target === CAR_WASH + if (!fronts && !data.safes.some((safe) => safe.propertyId === target)) return null + const before = state.safes[target] + const current = + before ?? + (fronts ? emptyFronts() : emptySafe(data.safes.find((x) => x.propertyId === target)!)) + return { + change: { safes: { ...state.safes, [target]: { ...current, money: 0 } } }, + // The money collected comes back on top of any payout since. + undo: (s) => { + const now = s.safes[target] ?? current + return { safes: { ...s.safes, [target]: { ...now, money: current.money + now.money } } } + }, + done: `${fronts ? 'Money Fronts' : label(target)} safe collected`, + } + } + case 'heat': { + const before = state.safes[target] ?? emptyFronts() + return { + change: { safes: { ...state.safes, [target]: legalJob(before, target) } }, + undo: (s) => ({ + safes: { + ...s.safes, + [target]: setHeat(s.safes[target] ?? before, target, heatOf(before, target)), + }, + }), + done: `Legal job at ${label(target)}`, + } + } + case 'lucky-wheel': { + const before = timers.luckyWheelSpunAt + return { + change: { timers: { ...timers, luckyWheelSpunAt: now } }, + undo: (s) => ({ timers: { ...s.timers, luckyWheelSpunAt: before } }), + done: 'Lucky Wheel spun', + } + } + case 'mansion-boost': { + const choices = boostableBusinesses( + data.catalog, + state.properties, + data.mansion, + timers.mansionBoost, + ) + if (!choice || !choices.some((p) => p.id === choice)) return null + const before = timers.mansionBoost + return { + change: { timers: { ...timers, mansionBoost: { propertyId: choice, startedAt: now } } }, + undo: (s) => ({ timers: { ...s.timers, mansionBoost: before } }), + done: `Mansion boost started on ${label(choice)}`, + } + } + case 'boost': { + const boost = data.productions.find((p) => p.propertyId === target)?.boost + if (!boost) return null + const before = state.boosts[target] + return { + change: { boosts: { ...state.boosts, [target]: startBoost(boost, now) } }, + undo: (s) => ({ boosts: restore(s.boosts, target, before) }), + done: `${label(target)} boost started`, + } + } + } + return null +} + +/** `record` with entry `id` put back to `value`, or removed if there was none. */ +function restore( + record: Record, + id: string, + value: T | undefined, +): Record { + const { [id]: _, ...rest } = record + return value === undefined ? rest : { ...rest, [id]: value } +} + +/** Nightclub popularity, if a nightclub is owned: it sets the safe's payout (decision 016). */ +export interface Popularity { + /** Percent. */ + value: number + /** The payout at this popularity, and the most it can be. */ + payout: number + best: number +} + +export function clubPopularity(state: AppState, data: TrackerData): Popularity | null { + for (const safe of data.safes) { + const property = data.catalog.find((p) => p.id === safe.propertyId) + if (!safe.popularity || !property || !isOwned(state.properties, property.id)) continue + const saved = state.safes[safe.propertyId] ?? emptySafe(safe) + const value = saved.popularity ?? 0 + return { + value, + payout: dailyPayout(safe, state.properties, property, value), + best: bestPayout(safe, state.properties, property), + } + } + return null +} diff --git a/src/lib/production.ts b/src/lib/production.ts index 1651118..2bfff51 100644 --- a/src/lib/production.ts +++ b/src/lib/production.ts @@ -2,6 +2,7 @@ // Pure logic: play time and wall-clock time come in as arguments (decision 003). import type { Production, ProductionBoost, ProductionRate } from '../data/production' import type { Property } from '../data/properties' +import type { NextEvent } from './events' import { isOwned, isRunning, isShutDown, upgradeValue, type OwnedProperties } from './properties' import { settleBars, type Bars } from './resync' import { mansionBoostOn, type MansionBoost, type MansionBoostData } from './timers' @@ -141,6 +142,52 @@ export function forecast( return later(plainForecast(produce(bars, production, fast, boostMs), production, rate), boostMs) } +/** The Mansion boost as production needs it: the last one started, and its data. */ +export interface MansionRun { + boost: MansionBoost | undefined + data: MansionBoostData +} + +/** + * What comes first for business `id` from wall-clock time `now`, with its production boost and + * the Mansion boost as they run then. Supplies bought and still on their way count from their + * arrival (issue #15): until then the business runs on what it has, and if the stock fills + * first that is what comes first. Play time is taken to run along with real time meanwhile. + */ +export function forecastAt( + bars: Bars, + production: Production, + rate: KnownRate, + id: string, + now: number, + { boost, mansion, fillsTo }: { boost?: Boost; mansion?: MansionRun; fillsTo: number }, +): Forecast { + const due = bars.suppliesDueAt + if (due !== undefined && due > now) { + const { suppliesDueAt: _, ...rest } = bars + const first = forecastAt(rest, production, rate, id, now, { boost, mansion, fillsTo }) + const waitMs = due - now + if (first.fullInMs !== null && first.fullInMs <= waitMs) return first + const run = runBusiness(bars, production, rate, id, now, due, fillsTo, boost, mansion) + const after = { boost: run.boost, mansion, fillsTo } + return later(forecastAt(run.bars, production, rate, id, due, after), waitMs) + } + const boosting = boostRunning(boost, production.boost, now) + const sped = mansion && mansionBoostOn(mansion.boost, mansion.data, id, now) + return forecast( + bars, + production, + rate, + boosting ? boost!.unitsLeft : 0, + sped + ? { + msLeft: mansion.boost!.startedAt + mansion.data.durationMs - now, + speed: mansion.data.speed, + } + : undefined, + ) +} + /** `forecast` as seen `ms` earlier. */ function later(ahead: Forecast, ms: number): Forecast { return { @@ -309,6 +356,40 @@ function step( return { bars: run.bars, boost: { ...boost, unitsLeft: run.unitsLeft } } } +/** + * Business `id` producing from `start` to `end` (wall clock, all of it play time): supplies + * bought count from their arrival, and a boost runs from its start to its end, as does the + * Mansion boost for the business it boosts. The boost counts the units it made. + */ +function runBusiness( + bars: Bars, + production: Production, + rate: KnownRate, + id: string, + start: number, + end: number, + fillsTo: number, + boost: Boost | undefined, + mansion: MansionRun | undefined, +): { bars: Bars; boost: Boost | undefined } { + // Where something changes during the stretch, in wall-clock time. + const ends = boost && production.boost ? boost.startedAt + production.boost.maxRealMs : 0 + const sped = mansion?.boost?.propertyId === id ? mansion.boost : undefined + const spedEnds = sped && sped.startedAt + mansion!.data.durationMs + const cuts = [bars.suppliesDueAt, boost?.startedAt, boost && ends, sped?.startedAt, spedEnds] + .filter((t): t is number => t !== undefined && t > start && t < end) + .sort((a, b) => a - b) + let at = start + for (const cut of [...cuts, end]) { + bars = settleBars(bars, at, fillsTo) + const fast = mansion && mansionBoostOn(mansion.boost, mansion.data, id, at) + const r = fast ? spedUp(rate, mansion.data.speed) : rate + ;({ bars, boost } = step(bars, production, r, boost, cut - at, at)) + at = cut + } + return { bars: settleBars(bars, end, fillsTo), boost } +} + /** * Counts a stretch of play time for every running business with a known rate: a shut-down one * keeps its bars as they are (issue #38). The stretch is @@ -327,7 +408,7 @@ export function produceAll( now: number, fillsTo: number, boosts: BoostsById = {}, - mansion?: { boost: MansionBoost | undefined; data: MansionBoostData }, + mansion?: MansionRun, ): { businesses: BusinessBarsById; boosts: BoostsById } { let next = { businesses, boosts } for (const production of productions) { @@ -336,31 +417,109 @@ export function produceAll( const rate = rateFor(production, owned, property) if (!rate) continue const id = property.id - let bars = businesses[id] ?? emptyBars() - let boost = boosts[id] - // Where something changes during the stretch, in wall-clock time. - const start = now - playMs - const ends = boost && production.boost ? boost.startedAt + production.boost.maxRealMs : 0 - const sped = mansion?.boost?.propertyId === id ? mansion.boost : undefined - const spedEnds = sped && sped.startedAt + mansion!.data.durationMs - const cuts = [bars.suppliesDueAt, boost?.startedAt, boost && ends, sped?.startedAt, spedEnds] - .filter((t): t is number => t !== undefined && t > start && t < now) - .sort((a, b) => a - b) - let at = start - for (const cut of [...cuts, now]) { - bars = settleBars(bars, at, fillsTo) - const fast = mansion && mansionBoostOn(mansion.boost, mansion.data, id, at) - const r = fast ? spedUp(rate, mansion.data.speed) : rate - ;({ bars, boost } = step(bars, production, r, boost, cut - at, at)) - at = cut - } - bars = settleBars(bars, now, fillsTo) + const { bars, boost } = runBusiness( + businesses[id] ?? emptyBars(), + production, + rate, + id, + now - playMs, + now, + fillsTo, + boosts[id], + mansion, + ) if (bars !== businesses[id]) next = { ...next, businesses: { ...next.businesses, [id]: bars } } if (boost !== boosts[id] && boost) next = { ...next, boosts: { ...next.boosts, [id]: boost } } } return next } +/** + * What comes next for each running business with a known rate (issue #15): stock full or + * supplies out, counting supplies on their way, and a production boost ready to start again. + */ +export function productionEvents( + businesses: BusinessBarsById, + catalog: readonly Property[], + productions: readonly Production[], + owned: OwnedProperties, + now: number, + fillsTo: number, + boosts: BoostsById, + mansion: MansionRun, +): NextEvent[] { + const events: NextEvent[] = [] + for (const production of productions) { + const property = catalog.find((p) => p.id === production.propertyId) + if (!property || !isRunning(owned, property.id)) continue + const rate = rateFor(production, owned, property) + if (!rate) continue + const id = property.id + const bars = settleBars(businesses[id] ?? emptyBars(), now, fillsTo) + const boost = boosts[id] + const sped = mansionBoostOn(mansion.boost, mansion.data, id, now) + const ahead = forecastAt(bars, production, rate, id, now, { boost, mansion, fillsTo }) + const parts = estimatedParts(rate) + // Both boosts at once: no source gives the speed (BusinessProduction says so). + const estimated = parts.production || (sped && boostRunning(boost, production.boost, now)) + const source = property.label + const tab = 'businesses' + if (ahead.fullInMs !== null) { + const value = stockValue(bars, production, rate, valueBonus(production, owned, property)) + events.push({ + id: `stock:${id}`, + source, + what: 'Stock full', + action: 'Sell', + inMs: ahead.fullInMs, + clock: 'play', + tone: 'good', + level: { value: bars.stock, max: 100, kind: 'stock' }, + value: value.far ?? value.nearby, + estimated: estimated || parts.value || farValueEstimated(production, owned), + tab, + focus: id, + quick: { label: 'Sold' }, + }) + } else { + events.push({ + id: `supplies:${id}`, + source, + what: 'Supplies out', + action: 'Resupply', + inMs: ahead.suppliesOutInMs ?? 0, + clock: 'play', + tone: 'bad', + level: { value: bars.supplies ?? 0, max: 100, kind: 'supplies' }, + estimated, + tab, + focus: id, + quick: { label: 'Bought' }, + }) + } + if ( + production.boost && + rate.boostedMinutesPerUnit !== undefined && + !boostRunning(boost, production.boost, now) + ) { + events.push({ + id: `boost:${id}`, + source, + what: 'Boost ready', + action: 'Start boost', + inMs: Math.max(0, nextBoostAt(boost, production.boost) - now), + clock: 'real', + tone: 'good', + estimated: false, + tab, + focus: id, + quick: { label: 'Started' }, + }) + } + } + return events +} + /** * Empties the stock of every business that `after` shuts down and `before` did not: the game * clears the stock but keeps the supplies (issue #38). Returns `businesses` itself if none. diff --git a/src/lib/safes.ts b/src/lib/safes.ts index e86a87d..455fc1e 100644 --- a/src/lib/safes.ts +++ b/src/lib/safes.ts @@ -2,6 +2,7 @@ // with it (issue #8, decision 016). Pure logic: play time comes in as an argument (decision 003). import type { Property } from '../data/properties' import { PAYOUT_MINUTES, type Safe } from '../data/safes' +import type { NextEvent } from './events' import { isOwned, upgradeValue, type OwnedProperties, type UpgradeValue } from './properties' /** Play time of one in-game day, the payout interval. */ @@ -185,6 +186,39 @@ export function advanceSafes( return next } +/** When each owned safe is full (issue #15), if it is paid anything. */ +export function safeEvents( + safes: SafesById, + data: readonly Safe[], + catalog: readonly Property[], + owned: OwnedProperties, +): NextEvent[] { + return data.flatMap((safe) => { + const property = catalog.find((p) => p.id === safe.propertyId) + if (!property || !isOwned(owned, property.id)) return [] + const state = safes[safe.propertyId] ?? emptySafe(safe) + const full = fullInMs(state, safe, owned, property) + if (full === null) return [] + return [ + { + id: `safe:${property.id}`, + source: property.label, + what: 'Safe full', + action: 'Collect', + inMs: full, + clock: 'play', + tone: 'good', + level: { value: state.money, max: capacity(safe, owned, property), kind: 'stock' }, + value: state.money, + estimated: estimatedParts(safe, owned, property).payout, + tab: 'safes', + focus: property.id, + quick: { label: 'Collected' }, + }, + ] + }) +} + /** * Checks saved safes. Entries are kept for any id, like owned properties (decision 011). * Returns an error message if the shape is wrong. diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index 8a7f0b1..b9feb7a 100644 --- a/src/lib/state.test.ts +++ b/src/lib/state.test.ts @@ -9,6 +9,9 @@ import { type Migration, } from './state' +// Version 12 defaults: alerts off, 10 minutes ahead, also while focused. +const ALERTS = { browser: false, popup: false, leadMs: 600_000, unfocusedOnly: false } + describe('parseState', () => { it('round-trips the default state', () => { const result = parseState(serializeState(defaultState())) @@ -25,7 +28,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 11, + version: 12, clock: { trackOnOpen: false }, properties: {}, businesses: {}, @@ -34,6 +37,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, + alerts: ALERTS, }, fromVersion: 1, }) @@ -44,7 +48,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 11, + version: 12, clock: { trackOnOpen: true }, properties: {}, businesses: {}, @@ -53,6 +57,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, + alerts: ALERTS, }, fromVersion: 2, }) @@ -66,7 +71,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 11, + version: 12, clock: { trackOnOpen: false }, properties, businesses: {}, @@ -75,6 +80,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, + alerts: ALERTS, }, fromVersion: 3, }) @@ -89,7 +95,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 11, + version: 12, clock: { trackOnOpen: true }, properties, businesses, @@ -98,6 +104,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, + alerts: ALERTS, }, fromVersion: 4, }) @@ -114,7 +121,15 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 11, nightclub: {}, safes: {}, boosts: {}, timers: {} }, + state: { + ...sample, + version: 12, + nightclub: {}, + safes: {}, + boosts: {}, + timers: {}, + alerts: ALERTS, + }, fromVersion: 5, }) }) @@ -130,7 +145,14 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 11, safes: {}, boosts: {}, timers: {} }, + state: { + ...sample, + version: 12, + safes: {}, + boosts: {}, + timers: {}, + alerts: ALERTS, + }, fromVersion: 6, }) }) @@ -147,7 +169,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 11, boosts: {}, timers: {} }, + state: { ...sample, version: 12, boosts: {}, timers: {}, alerts: ALERTS }, fromVersion: 7, }) }) @@ -165,7 +187,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 11, timers: {} }, + state: { ...sample, version: 12, timers: {}, alerts: ALERTS }, fromVersion: 8, }) }) @@ -183,7 +205,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 11, timers: {} }, + state: { ...sample, version: 12, timers: {}, alerts: ALERTS }, fromVersion: 9, }) }) @@ -201,11 +223,41 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 11, timers: {} }, + state: { ...sample, version: 12, timers: {}, alerts: ALERTS }, fromVersion: 10, }) }) + it('migrates a version 11 sample, with alerts off', () => { + const sample = { + version: 11, + clock: { trackOnOpen: false }, + properties: { bunker: { owned: true, upgrades: {} } }, + businesses: { bunker: { stock: 40, supplies: 80 } }, + businessOrder: [], + nightclub: {}, + safes: {}, + boosts: {}, + timers: { luckyWheelSpunAt: 5_000 }, + } + expect(parseState(JSON.stringify(sample))).toEqual({ + ok: true, + state: { ...sample, version: 12, alerts: ALERTS }, + fromVersion: 11, + }) + }) + + it('keeps the alert settings', () => { + const alerts = { browser: true, popup: true, leadMs: 0, unfocusedOnly: true } + const text = JSON.stringify({ ...defaultState(), alerts }) + expect(parseState(text)).toMatchObject({ ok: true, state: { alerts } }) + }) + + it('rejects unreadable alert settings', () => { + const text = JSON.stringify({ ...defaultState(), alerts: { ...ALERTS, leadMs: -1 } }) + expect(parseState(text)).toMatchObject({ ok: false, reason: 'invalid' }) + }) + it('keeps saved timers', () => { const timers = { luckyWheelSpunAt: 5_000, diff --git a/src/lib/state.ts b/src/lib/state.ts index 41f2f20..339f190 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -13,7 +13,7 @@ import { parseSafes, type SafesById } from './safes' import { parseTimers, type Timers } from './timers' /** Bump when the stored shape changes, and add a migration from the previous version. */ -export const CURRENT_VERSION = 11 +export const CURRENT_VERSION = 12 /** All user state. Trackers add their own sections here as they are built. */ export interface AppState { @@ -34,6 +34,23 @@ export interface AppState { boosts: BoostsById /** Real-time timers: the Lucky Wheel and the Mansion boost (issues #13, #14). */ timers: Timers + /** Alerts when an Overview item comes due (issue #15); off until opted in. */ + alerts: AlertSettings +} + +export interface AlertSettings { + /** Browser (system) notifications; the browser asks for permission first. */ + browser: boolean + /** Pop-ups inside LSOS, on any tab. */ + popup: boolean + /** How long before an item is due to alert; 0 alerts when it is due. */ + leadMs: number + /** Alert only while LSOS is not the focused window, e.g. while in the game. */ + unfocusedOnly: boolean +} + +export function defaultAlerts(): AlertSettings { + return { browser: false, popup: false, leadMs: 10 * 60_000, unfocusedOnly: false } } export function defaultState(): AppState { @@ -47,6 +64,7 @@ export function defaultState(): AppState { safes: {}, boosts: {}, timers: {}, + alerts: defaultAlerts(), } } @@ -79,6 +97,8 @@ export const MIGRATIONS: Readonly> = { 9: (data) => ({ ...data }), // Version 11 adds the real-time timers (issues #13, #14); none started yet. 10: (data) => ({ ...data, timers: {} }), + // Version 12 adds the alert settings (issue #15); alerts are off until opted in. + 11: (data) => ({ ...data, alerts: defaultAlerts() }), } export type ParseResult = @@ -163,6 +183,8 @@ function toState(data: StoredData): AppState | string { if (typeof boosts === 'string') return boosts const timers = parseTimers(data.timers) if (typeof timers === 'string') return timers + const alerts = toAlerts(data.alerts) + if (typeof alerts === 'string') return alerts return { version: CURRENT_VERSION, clock, @@ -173,6 +195,7 @@ function toState(data: StoredData): AppState | string { safes, boosts, timers, + alerts, } } @@ -183,6 +206,22 @@ function toClock(data: unknown): ClockSettings | string { return { trackOnOpen } } +function toAlerts(data: unknown): AlertSettings | string { + if (!isRecord(data)) return 'The alerts section is missing.' + const { browser, popup, leadMs, unfocusedOnly } = data + if ( + typeof browser !== 'boolean' || + typeof popup !== 'boolean' || + typeof unfocusedOnly !== 'boolean' || + typeof leadMs !== 'number' || + !Number.isFinite(leadMs) || + leadMs < 0 + ) { + return 'The saved alert settings are not readable.' + } + return { browser, popup, leadMs, unfocusedOnly } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/lib/tabs.test.ts b/src/lib/tabs.test.ts index 4679b65..892a1b8 100644 --- a/src/lib/tabs.test.ts +++ b/src/lib/tabs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_TAB, TABS, hashForTab, tabFromHash } from './tabs' +import { DEFAULT_TAB, TABS, focusFromHash, hashForTab, tabFromHash } from './tabs' describe('tabFromHash', () => { it('round-trips every tab through its hash', () => { @@ -15,3 +15,17 @@ describe('tabFromHash', () => { expect(tabFromHash('#/nope')).toBe(DEFAULT_TAB) }) }) + +describe('focusFromHash', () => { + it('round-trips an item on a tab', () => { + const hash = hashForTab('safes', 'salvage-yard') + expect(hash).toBe('#/safes/salvage-yard') + expect(tabFromHash(hash)).toBe('safes') + expect(focusFromHash(hash)).toBe('salvage-yard') + }) + + it('is undefined without an item', () => { + expect(focusFromHash('#/safes')).toBeUndefined() + expect(focusFromHash('')).toBeUndefined() + }) +}) diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts index b9935ee..3d90479 100644 --- a/src/lib/tabs.ts +++ b/src/lib/tabs.ts @@ -11,12 +11,22 @@ export type TabId = (typeof TABS)[number]['id'] export const DEFAULT_TAB: TabId = 'overview' -/** Maps a location hash like `#/safes` to a tab id, falling back to the default tab. */ +/** + * Maps a location hash like `#/safes` (or `#/safes/agency`) to a tab id, falling back to the + * default tab. + */ export function tabFromHash(hash: string): TabId { - const id = hash.replace(/^#\/?/, '') + const id = hash.replace(/^#\/?/, '').split('/')[0] return TABS.find((tab) => tab.id === id)?.id ?? DEFAULT_TAB } -export function hashForTab(id: TabId): string { - return `#/${id}` +/** What to scroll to and highlight on the tab, e.g. `agency` in `#/safes/agency` (issue #15). */ +export function focusFromHash(hash: string): string | undefined { + const rest = hash.replace(/^#\/?/, '').split('/').slice(1).join('/') + return rest ? decodeURIComponent(rest) : undefined +} + +/** The hash of a tab, or of one item on it to scroll to and highlight. */ +export function hashForTab(id: TabId, focus?: string): string { + return focus ? `#/${id}/${encodeURIComponent(focus)}` : `#/${id}` } diff --git a/src/lib/timers.ts b/src/lib/timers.ts index bc19e64..977e452 100644 --- a/src/lib/timers.ts +++ b/src/lib/timers.ts @@ -1,6 +1,7 @@ // Real-time timers (issues #13, #14): the Lucky Wheel cooldown and the Mansion production boost. // Pure logic: wall-clock time comes in as an argument (decision 003). -import type { MANSION_BOOST } from '../data/timers' +import type { LUCKY_WHEEL, MANSION_BOOST } from '../data/timers' +import type { NextEvent } from './events' import type { Property } from '../data/properties' import { isOwned, isRunning, type OwnedProperties } from './properties' @@ -67,6 +68,53 @@ export function boostableBusinesses( ) } +/** + * When the Lucky Wheel can be spun, and when the Mansion boost can be started again if a Mansion + * and a business it can boost are owned (issue #15). Both on real time. + */ +export function timerEvents( + timers: Timers, + catalog: readonly Property[], + owned: OwnedProperties, + now: number, + wheel: typeof LUCKY_WHEEL, + mansion: MansionBoostData, +): NextEvent[] { + const events: NextEvent[] = [ + { + id: 'lucky-wheel', + source: 'Lucky Wheel', + what: 'Spin ready', + action: 'Spin', + inMs: Math.max(0, readyAt(timers.luckyWheelSpunAt, wheel.cooldownMs) - now), + clock: 'real', + tone: 'good', + estimated: false, + tab: 'timers', + focus: 'lucky-wheel', + quick: { label: 'Spun' }, + }, + ] + const last = timers.mansionBoost + const choices = boostableBusinesses(catalog, owned, mansion, last) + if (isOwned(owned, mansion.propertyId) && choices.length > 0) { + events.push({ + id: 'mansion-boost', + source: catalog.find((p) => p.id === mansion.propertyId)?.label ?? 'Mansion', + what: 'Boost ready', + action: 'Start boost', + inMs: Math.max(0, readyAt(last?.startedAt, mansion.cooldownMs) - now), + clock: 'real', + tone: 'good', + estimated: false, + tab: 'timers', + focus: 'mansion', + quick: { label: 'Started', choices: choices.map((p) => ({ id: p.id, label: p.label })) }, + }) + } + return events +} + /** Checks saved timers. Returns an error message if the shape is wrong. */ export function parseTimers(data: unknown): Timers | string { if (!isRecord(data)) return 'The timers section is missing.'