diff --git a/CLAUDE.md b/CLAUDE.md index 126e8bc..54c69d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Money Fronts safe and heat | `data/fronts.ts`, `lib/fronts.ts` (018) | | 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) | | 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/docs/decisions/010-resync.md b/docs/decisions/010-resync.md index 8192fa4..8875b85 100644 --- a/docs/decisions/010-resync.md +++ b/docs/decisions/010-resync.md @@ -1,6 +1,6 @@ # 010: Resync: correcting tracked values by hand -2026-10-14, issue #4. Builds on decision 003 ("Resync"). +2026-10-14, issue #4. Builds on decision 003 ("Resync"). Duration format amended by decision 022. ## Decision diff --git a/docs/decisions/022-real-time-timers.md b/docs/decisions/022-real-time-timers.md new file mode 100644 index 0000000..a82346a --- /dev/null +++ b/docs/decisions/022-real-time-timers.md @@ -0,0 +1,36 @@ +# 022: Timers tab: Lucky Wheel and Mansion boost on real time + +2026-10-15, issues #13 and #14. Builds on decisions 003, 010, 012 and 017. + +## Decision + +- A **Timers** tab holds cooldowns that run on **real time** (decision 003), also while LSOS is + closed. Values and sources are in `src/data/timers.ts`; the logic is in `src/lib/timers.ts`. +- **Lucky Wheel:** "Spun" starts a 24-hour real-time countdown to the next spin. The extra daily + GTA+ spin is left out because no source says how it is timed. +- **Mansion boost:** the Mansion is an owned property with no card (decision 011), and the boost + card shows only while you own one. You pick an eligible business that is owned and running, + then press "Activated". That business makes stock 3× as fast for 24 real hours. The next boost + can start 24 real hours after the last one started (gta.wiki, Mansions), and never on the same + business twice in a row. +- **Production:** `produceAll` cuts a play-time stretch at the boost's real-time start and end, + the same way it cuts at the Acid Lab boost (decision 017), and runs the boosted business at its + rate ÷ 3. Supplies per unit stay the same, so they run out 3× as fast. The business card shows + the time left on the boost, and its forecast counts that time as play time. +- **With the Acid Lab's own boost:** no source gives the combined speed, so LSOS takes the + boosted rate ÷ 3 and marks the production numbers "(est.)" while both are on (decision 013). +- Both countdowns can be corrected as "time left" (decision 010). A `duration` value now shows + as h:mm:ss like every `Countdown`, so it visibly ticks. It accepts h:mm, h:mm:ss, "5h 30m", or + plain minutes. After "Spun" or "Activated", an Undo button for that press shows in the card's + footer strip. It restores the previous value, including the last boost the back-to-back rule + needs. +- **Saved state** (version 11): `timers` has `luckyWheelSpunAt` and `mansionBoost` + (`propertyId`, `startedAt`). Each is left out until first used. + +## Why + +These cooldowns don't depend on play, and a separate tab keeps them apart from the play-time +trackers. One community guide puts the Mansion boost's reset at the daily server reset +(6:00 UTC). gta.wiki says 24 real hours, and gta.wiki is the trusted source. The boost is saved +with the Timers state rather than in `boosts`, because it belongs to the Mansion and not to the +business it speeds up. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 89c5c65..04ca7b2 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -20,7 +20,7 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [007](007-state-store.md) State store: unreadable data blocks saving like a newer version; import overrides - [008](008-clock-details.md) Clock details: session play time, opens paused (setting), throttled ticks counted, long gaps asked about - [009](009-terminal-design.md) Terminal look: tokens in `app.css`, self-hosted Oswald (OFL), browser-style tabs, base components in `src/components/ui/` -- [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since, five-segment bars you can drag +- [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since, five-segment bars you can drag (duration format amended by 022) - [011](011-owned-properties.md) Owned properties and upgrades: catalog in `src/data/properties.ts`, toggle/choice upgrades (count: 016), Settings panel and Businesses gear - [012](012-business-production.md) Business production: rate per exact upgrade combination in `src/data/production.ts`, continuous on play time, unknown rates do not run - [013](013-marked-estimates.md) Missing game values may be estimated by a stated rule from sourced ones, marked "(est.)" wherever shown @@ -32,3 +32,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [019](019-owned-value-bonus.md) Money Front value bonus: owning Car Wash or Smoke on the Water adds 35% to Counterfeit Cash or Weed, far value adds the bonuses (est.), Helitours left out - [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.) diff --git a/src/App.svelte b/src/App.svelte index cfd201c..571cc87 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,6 +5,7 @@ import Safes from './components/Safes.svelte' import SaveStatus from './components/SaveStatus.svelte' import Settings from './components/Settings.svelte' + import Timers from './components/Timers.svelte' import TabBar from './components/ui/TabBar.svelte' import { GOODS } from './data/nightclub' import { PRODUCTION } from './data/production' @@ -57,6 +58,7 @@ now={clocks.now} onresync={(id, bars) => (store.state.businesses = { ...store.state.businesses, [id]: bars })} + mansionBoost={store.state.timers.mansionBoost} boosts={store.state.boosts} onboost={(id, boost) => { const { [id]: _, ...rest } = store.state.boosts @@ -78,6 +80,14 @@ stored={store.state.safes} onresync={(id, safe) => (store.state.safes = { ...store.state.safes, [id]: safe })} /> + {:else if active === 'timers'} + (store.state.timers = timers)} + /> {:else if active === 'settings'} {:else} diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte index c83e193..89f22a1 100644 --- a/src/components/BusinessProduction.svelte +++ b/src/components/BusinessProduction.svelte @@ -1,9 +1,11 @@ @@ -106,42 +124,47 @@ {/if} {#if value} -
- {est('Value', estimated.value || estimated.production)} + + {#if value.far !== null} - - - {formatValue(value.far, 'money')} far{farEstimated ? ' (est.)' : ''} - · {formatValue(value.nearby, 'money')} nearby - + {formatValue(value.far, 'money')} far{farEstimated ? ' (est.)' : ''} + · {formatValue(value.nearby, 'money')} nearby {:else} - {formatValue(value.nearby, 'money')} + {formatValue(value.nearby, 'money')} {/if} -
+ {/if} {#if production.boost && running?.boostedMinutesPerUnit !== undefined && !shutDown} -
- {#if boosting} - Boost on - - {Math.ceil(boost!.unitsLeft)} - {production.unitName} left - - - {:else if now >= nextBoost} - Boost ready + {#if boosting} + + {Math.ceil(boost!.unitsLeft)} + {production.unitName} left + + + {:else if now >= nextBoost} + - {/if} -
+ + {/if} {#if !boosting && now < nextBoost} {/if} {/if} +{#if mansionOn && !shutDown} + +{/if} + {#if shutDown}

Shut down. No stock is made and no supplies are used until you start it again. @@ -167,6 +190,12 @@ Estimate. No reliable data for these upgrades. {rate.estimate.how}

{/if} + {#if stacked} +

+ Estimate. No source gives the speed with both boosts on: LSOS takes the + boosted speed ×{MANSION_BOOST.speed}. +

+ {/if} {:else}

Not tracked. @@ -180,26 +209,6 @@ {/if} diff --git a/src/components/SafeCard.svelte b/src/components/SafeCard.svelte index 6410a8d..4dcecf1 100644 --- a/src/components/SafeCard.svelte +++ b/src/components/SafeCard.svelte @@ -19,6 +19,7 @@ } from '../lib/safes' import Button from './ui/Button.svelte' import Countdown from './ui/Countdown.svelte' + import Fact from './ui/Fact.svelte' import EditableBar from './ui/EditableBar.svelte' import EditableValue from './ui/EditableValue.svelte' import Meter from './ui/Meter.svelte' @@ -103,10 +104,7 @@ /> {/if} -

- {est('Payout', estimated.payout)} - {payoutText} -
+{payoutText} {#if editing} @@ -132,25 +130,6 @@ margin: 0; } - .facts { - align-items: baseline; - display: flex; - gap: var(--space-3); - justify-content: space-between; - } - - .label { - color: var(--muted); - font-family: var(--font-display); - font-size: var(--text-sm); - letter-spacing: var(--tracking); - text-transform: uppercase; - } - - .value { - font-variant-numeric: tabular-nums; - } - .sync { align-items: center; display: flex; diff --git a/src/components/Timers.svelte b/src/components/Timers.svelte new file mode 100644 index 0000000..0b1e7ba --- /dev/null +++ b/src/components/Timers.svelte @@ -0,0 +1,178 @@ + + + +{#snippet wheelUndo()} + +{/snippet} + +{#snippet mansionUndo()} + +{/snippet} + +
+ + {#if now < spinAt} + + onchange({ + ...timers, + luckyWheelSpunAt: startForRemaining(ms, LUCKY_WHEEL.cooldownMs, now), + })} + /> + {:else} + + + + {/if} +

One spin per 24 real hours, also while LSOS is closed.

+
+ + + {#if !mansionOwned} +

+ The production boost from the Mansion's AI assistant. Mark a Mansion as owned in + Settings to track it. +

+ {:else} + {#if boostOn} + {label(last!.propertyId)} + + onchange({ + ...timers, + mansionBoost: { + ...last!, + startedAt: startForRemaining(ms, MANSION_BOOST.durationMs, now), + }, + })} + /> + {/if} + {#if now < nextBoostAt && nextBoostAt !== endsAt} + + {:else if now >= nextBoostAt} + {#if choices.length > 0} + + (picked = id)} + /> + +
+ + // A dropdown of options by id; the caller keeps the chosen one. + interface Option { + id: string + label: string + } + + interface Props { + id: string + value: string | undefined + options: readonly Option[] + onchange: (id: string) => void + } + + let { id, value, options, onchange }: Props = $props() + + + + + diff --git a/src/data/properties.ts b/src/data/properties.ts index 2d8ffdc..08e9e21 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -56,6 +56,7 @@ import { WAREHOUSE_SIZE_UPGRADE, } from './fronts' import { ARCADE_SLOTS, SAFES, SAFES_CHECKED } from './safes' +import { MANSION_BOOST } from './timers' // Equipment and staff speed up production and raise the value; equipment also makes supplies // last longer. The security upgrade only changes raids, which LSOS does not predict: left out. @@ -125,6 +126,15 @@ export const PROPERTIES: readonly Property[] = [ source: NIGHTCLUB_SOURCE, checked: NIGHTCLUB_CHECKED, }, + // Owning one gives the business production boost on the Timers tab (issue #14). + { + id: MANSION_BOOST.propertyId, + label: 'Mansion', + upgrades: [], + card: false, + source: MANSION_BOOST.source, + checked: MANSION_BOOST.checked, + }, { id: 'nightclub', label: 'Nightclub', diff --git a/src/data/timers.ts b/src/data/timers.ts new file mode 100644 index 0000000..96dc9f7 --- /dev/null +++ b/src/data/timers.ts @@ -0,0 +1,42 @@ +// Real-time cooldowns on the Timers tab (issues #13, #14): they run on the wall clock, also while +// LSOS is closed (decision 003). Every value has a source (decision 004). + +const HOUR_MS = 60 * 60_000 +const CHECKED = '2026-10-15' + +/** The Diamond Casino's Lucky Wheel: one spin per cooldown. */ +export const LUCKY_WHEEL = { + cooldownMs: 24 * HOUR_MS, + source: + 'gta.wiki, Lucky Wheel: "can only be used once per real-life 24 hours per account". The ' + + 'extra daily GTA+ spin is left out: no source says how it is timed.', + checked: CHECKED, +} as const + +/** The Mansion's business production boost, started through the AI assistant. */ +export const MANSION_BOOST = { + /** Owning this property gives the boost. */ + propertyId: 'mansion', + /** Production runs this many times as fast while the boost is on. */ + speed: 3, + durationMs: 24 * HOUR_MS, + /** Real time from one boost's start until the next can be started. */ + cooldownMs: 24 * HOUR_MS, + /** The businesses it can boost, one at a time, never the same one twice in a row. */ + propertyIds: [ + 'mc-cocaine', + 'mc-meth', + 'mc-cash', + 'mc-weed', + 'mc-documents', + 'bunker', + 'acid-lab', + ], + source: + 'gta.wiki, Prix Luxury AI Concierge: "increase the production speed threefold at one of ' + + 'the player\'s businesses", "lasting 24 real-life hours", for the Meth Lab, Weed Farm, ' + + 'Cocaine Lockup, Counterfeit Cash Factory, Document Forgery Office, Bunkers and Acid Lab; ' + + '"The same business can\'t be boosted back-to-back". gta.wiki, Mansions: "once a day ' + + '(cooldown of 24 real-life hours)".', + checked: CHECKED, +} as const diff --git a/src/lib/production.svelte.ts b/src/lib/production.svelte.ts index 0d702d3..848d7bb 100644 --- a/src/lib/production.svelte.ts +++ b/src/lib/production.svelte.ts @@ -1,9 +1,10 @@ -// Runs business and nightclub production on play time (issues #6, #7). Logic lives in production.ts; this file +// Runs business and nightclub production on play time (issues #6, #7), with the Mansion boost (#14). Logic lives in production.ts; this file // only wires it to the play clock and the store. import { SUPPLY_PURCHASE } from '../data/business-events' import { GOODS } from '../data/nightclub' import { PRODUCTION } from '../data/production' import { PROPERTIES } from '../data/properties' +import { MANSION_BOOST } from '../data/timers' import { onPlayTime } from './clock.svelte' import { produceGoods } from './nightclub' import { produceAll } from './production' @@ -19,6 +20,7 @@ onPlayTime((ms) => { Date.now(), SUPPLY_PURCHASE.fillsTo, store.state.boosts, + { boost: store.state.timers.mansionBoost, data: MANSION_BOOST }, ) store.state.businesses = produced.businesses store.state.boosts = produced.boosts diff --git a/src/lib/production.test.ts b/src/lib/production.test.ts index ee3c0a4..f7d132d 100644 --- a/src/lib/production.test.ts +++ b/src/lib/production.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { PRODUCTION, type Production } from '../data/production' import { PROPERTIES } from '../data/properties' +import { MANSION_BOOST } from '../data/timers' import { clearShutDownStock, estimatedParts, @@ -391,6 +392,65 @@ describe('acid lab', () => { }) }) +describe('mansion boost (issue #14)', () => { + const owned = owning('bunker', { equipment: true, staff: true }) + const DAY = 24 * 60 * MIN + const mansion = (propertyId: string, startedAt = 0) => ({ + boost: { propertyId, startedAt }, + data: MANSION_BOOST, + }) + const run = (playMs: number, now: number, boosted = mansion('bunker')) => + produceAll( + { bunker: { stock: 0, supplies: 100 } }, + PROPERTIES, + PRODUCTION, + owned, + playMs, + now, + 100, + {}, + boosted, + ).businesses.bunker + + it('makes three times as much while on, using supplies per unit as before', () => { + expect(run(7 * MIN, 7 * MIN)).toEqual({ stock: 3, supplies: 85 }) + }) + + it('stops after 24 real hours', () => { + // 7 minutes boosted (3 units), then 7 minutes not (1 unit). + const bars = run(14 * MIN, DAY + 7 * MIN)! + expect(bars.stock).toBeCloseTo(4, 9) + expect(bars.supplies).toBeCloseTo(80, 9) + }) + + it('starts when it was activated and only for the business boosted', () => { + // 7 minutes before the start (1 unit), then 7 minutes boosted (3 units). + expect(run(14 * MIN, 14 * MIN, mansion('bunker', 7 * MIN))!.stock).toBeCloseTo(4, 9) + expect(run(7 * MIN, 7 * MIN, mansion('mc-weed'))).toEqual({ stock: 1, supplies: 95 }) + }) + + it('forecasts three times as fast until it ends', () => { + const bars = { stock: 0, supplies: 100 } + expect(forecast(bars, bunker, fast, 0, { msLeft: DAY, speed: 3 }).suppliesOutInMs).toBeCloseTo( + (140 / 3) * MIN, + 6, + ) + // 9 units in 21 minutes, then the other 11 units of supplies at 7 minutes each. + expect(forecast(bars, bunker, fast, 0, { msLeft: 21 * MIN, speed: 3 })).toEqual({ + fullInMs: null, + suppliesOutInMs: 98 * MIN, + }) + }) + + it('speeds up the Acid Lab boost too, by estimate', () => { + const rate = rateFor(acid, owning('acid-lab', { equipment: true }), property('acid-lab'))! + // 80 units at 15 s, then 20 at 30 s until supplies run out. + const ahead = forecast({ stock: 0, supplies: 100 }, acid, rate, 80, { msLeft: DAY, speed: 3 }) + expect(ahead.fullInMs).toBeNull() + expect(ahead.suppliesOutInMs).toBeCloseTo(30 * MIN, 6) + }) +}) + describe('parseBoosts', () => { it('reads boosts', () => { const data = { 'acid-lab': { startedAt: 5, unitsLeft: 0 } } diff --git a/src/lib/production.ts b/src/lib/production.ts index 577d9cf..1651118 100644 --- a/src/lib/production.ts +++ b/src/lib/production.ts @@ -4,6 +4,7 @@ import type { Production, ProductionBoost, ProductionRate } from '../data/produc import type { Property } from '../data/properties' import { isOwned, isRunning, isShutDown, upgradeValue, type OwnedProperties } from './properties' import { settleBars, type Bars } from './resync' +import { mansionBoostOn, type MansionBoost, type MansionBoostData } from './timers' /** A rate with everything needed to run production on its own. */ export type KnownRate = ProductionRate & { unitsPerFullSupplies: number } @@ -105,16 +106,31 @@ export interface Forecast { suppliesOutInMs: number | null } +/** A speed-up for part of a forecast: `speed` times as fast for the next `msLeft`. */ +export interface SpeedUp { + msLeft: number + speed: number +} + /** * What comes first, in play time. With `boostUnitsLeft`, that many units are made at the - * boosted speed first (the rate must have one). + * boosted speed first (the rate must have one). With `speedUp`, everything runs that much + * faster until its time is up, counted as play time (the Mansion boost, issue #14). */ export function forecast( bars: Bars, production: Production, rate: KnownRate, boostUnitsLeft = 0, + speedUp?: SpeedUp, ): Forecast { + if (speedUp && speedUp.msLeft > 0 && speedUp.speed !== 1) { + const fast = spedUp(rate, speedUp.speed) + const during = forecast(bars, production, fast, boostUnitsLeft) + if ((during.fullInMs ?? during.suppliesOutInMs ?? 0) <= speedUp.msLeft) return during + const after = boostedRun(bars, production, fast, boostUnitsLeft, speedUp.msLeft) + return later(forecast(after.bars, production, rate, after.unitsLeft), speedUp.msLeft) + } if (boostUnitsLeft <= 0 || rate.boostedMinutesPerUnit === undefined) { return plainForecast(bars, production, rate) } @@ -122,10 +138,14 @@ export function forecast( const boostMs = boostUnitsLeft * fast.minutesPerUnit * MINUTE_MS const boosted = plainForecast(bars, production, fast) if ((boosted.fullInMs ?? boosted.suppliesOutInMs ?? 0) <= boostMs) return boosted - const rest = plainForecast(produce(bars, production, fast, boostMs), production, rate) + return later(plainForecast(produce(bars, production, fast, boostMs), production, rate), boostMs) +} + +/** `forecast` as seen `ms` earlier. */ +function later(ahead: Forecast, ms: number): Forecast { return { - fullInMs: rest.fullInMs === null ? null : rest.fullInMs + boostMs, - suppliesOutInMs: rest.suppliesOutInMs === null ? null : rest.suppliesOutInMs + boostMs, + fullInMs: ahead.fullInMs === null ? null : ahead.fullInMs + ms, + suppliesOutInMs: ahead.suppliesOutInMs === null ? null : ahead.suppliesOutInMs + ms, } } @@ -232,6 +252,43 @@ export function nextBoostAt(boost: Boost | undefined, data: ProductionBoost): nu return reset > boost.startedAt ? reset : reset + DAY_MS } +/** `rate` running `speed` times as fast, e.g. during the Mansion boost (issue #14). */ +export function spedUp(rate: KnownRate, speed: number): KnownRate { + if (speed === 1) return rate + return { + ...rate, + minutesPerUnit: rate.minutesPerUnit / speed, + ...(rate.boostedMinutesPerUnit !== undefined && { + boostedMinutesPerUnit: rate.boostedMinutesPerUnit / speed, + }), + } +} + +/** + * `ms` of production with `unitsLeft` units still to make at the boosted speed (the rate must + * have one), then at the normal speed. Returns the units the boost still has left. + */ +function boostedRun( + bars: Bars, + production: Production, + rate: KnownRate, + unitsLeft: number, + ms: number, +): { bars: Bars; unitsLeft: number } { + const boosted = rate.boostedMinutesPerUnit + if (unitsLeft <= 0 || boosted === undefined) { + return { bars: produce(bars, production, rate, ms), unitsLeft } + } + const fast: KnownRate = { ...rate, minutesPerUnit: boosted } + const boostMs = Math.min(ms, unitsLeft * boosted * MINUTE_MS) + const after = produce(bars, production, fast, boostMs) + if (after === bars) return { bars, unitsLeft } + const made = ((after.stock - bars.stock) * production.maxUnits) / 100 + // The small margin ends the boost despite floating-point leftovers. + const left = unitsLeft - made < 1e-6 ? 0 : unitsLeft - made + return { bars: produce(after, production, rate, ms - boostMs), unitsLeft: left } +} + /** * `ms` of production starting at wall-clock time `at`, boosted while the boost runs. The boost * counts the units it made. @@ -244,28 +301,20 @@ function step( ms: number, at: number, ): { bars: Bars; boost: Boost | undefined } { - const boosted = rate.boostedMinutesPerUnit - if (!boost || boosted === undefined || !boostRunning(boost, production.boost, at)) { + if (!boost || !boostRunning(boost, production.boost, at)) { return { bars: produce(bars, production, rate, ms), boost } } - const fast: KnownRate = { ...rate, minutesPerUnit: boosted } - const boostMs = Math.min(ms, boost.unitsLeft * boosted * MINUTE_MS) - const after = produce(bars, production, fast, boostMs) - if (after === bars) return { bars, boost } - const made = ((after.stock - bars.stock) * production.maxUnits) / 100 - // The small margin ends the boost despite floating-point leftovers. - const unitsLeft = boost.unitsLeft - made < 1e-6 ? 0 : boost.unitsLeft - made - return { - bars: produce(after, production, rate, ms - boostMs), - boost: { ...boost, unitsLeft }, - } + const run = boostedRun(bars, production, rate, boost.unitsLeft, ms) + if (run.unitsLeft === boost.unitsLeft) return { bars: run.bars, boost } + return { bars: run.bars, boost: { ...boost, unitsLeft: run.unitsLeft } } } /** * 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 * taken to end at `now` (wall clock), which is exact for normal ticks: supplies bought that - * arrived during it count from their arrival, and a boost runs from its start to its end. + * arrived during it count from their arrival, and a boost runs from its start to its end, as + * does the Mansion boost (`mansion`, issue #14) for the business it boosts. * Returns `businesses` and `boosts` themselves if nothing changed, so an idle tick does not * trigger a save. */ @@ -278,6 +327,7 @@ export function produceAll( now: number, fillsTo: number, boosts: BoostsById = {}, + mansion?: { boost: MansionBoost | undefined; data: MansionBoostData }, ): { businesses: BusinessBarsById; boosts: BoostsById } { let next = { businesses, boosts } for (const production of productions) { @@ -291,13 +341,17 @@ export function produceAll( // 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 cuts = [bars.suppliesDueAt, boost?.startedAt, boost && ends] + 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) - ;({ bars, boost } = step(bars, production, rate, boost, cut - at, at)) + 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) diff --git a/src/lib/resync.test.ts b/src/lib/resync.test.ts index a2ad693..2afcb18 100644 --- a/src/lib/resync.test.ts +++ b/src/lib/resync.test.ts @@ -14,19 +14,20 @@ import { const MIN = 60_000 describe('formatValue', () => { - it('shows percent, money and h:mm', () => { + it('shows percent, money and h:mm:ss', () => { expect(formatValue(39.6, 'percent')).toBe('40%') expect(formatValue(1_234_567, 'money')).toBe('$1,234,567') - expect(formatValue(65 * MIN, 'duration')).toBe('1:05') + expect(formatValue(65 * MIN, 'duration')).toBe('1:05:00') expect(formatValue(11.7, 'crates')).toBe('11') expect(formatValue(3.9999999, 'crates')).toBe('4') }) - it('rounds time left up to the minute, so 0:00 means done', () => { - expect(formatValue(1, 'duration')).toBe('0:01') - expect(formatValue(59 * MIN + 1, 'duration')).toBe('1:00') - expect(formatValue(0, 'duration')).toBe('0:00') - expect(formatValue(-5 * MIN, 'duration')).toBe('0:00') + it('rounds time left up to the second, so 0:00:00 means done', () => { + expect(formatValue(1, 'duration')).toBe('0:00:01') + expect(formatValue(59 * MIN + 1, 'duration')).toBe('0:59:01') + expect(formatValue(24 * 60 * MIN, 'duration')).toBe('24:00:00') + expect(formatValue(0, 'duration')).toBe('0:00:00') + expect(formatValue(-5 * MIN, 'duration')).toBe('0:00:00') }) }) @@ -43,8 +44,13 @@ describe('parseValue', () => { expect(parseValue('1.2M', 'money')).toBe(1_200_000) }) - it('reads h:mm or plain minutes as milliseconds', () => { + it('reads h:mm, h:mm:ss, hours and minutes, or plain minutes as milliseconds', () => { expect(parseValue('1:05', 'duration')).toBe(65 * MIN) + expect(parseValue('21:30', 'duration')).toBe((21 * 60 + 30) * MIN) + expect(parseValue('1:05:30', 'duration')).toBe(65 * MIN + 30_000) + expect(parseValue('5h 30m', 'duration')).toBe(330 * MIN) + expect(parseValue('5h', 'duration')).toBe(300 * MIN) + expect(parseValue('30 min', 'duration')).toBe(30 * MIN) expect(parseValue('0:7', 'duration')).toBe(7 * MIN) expect(parseValue('45', 'duration')).toBe(45 * MIN) expect(parseValue('0', 'duration')).toBe(0) @@ -64,7 +70,9 @@ describe('parseValue', () => { expect(parseValue('1e3', 'percent')).toBeNull() expect(parseValue('$', 'money')).toBeNull() expect(parseValue('1:60', 'duration')).toBeNull() - expect(parseValue('1:2:3', 'duration')).toBeNull() + expect(parseValue('1:2:3:4', 'duration')).toBeNull() + expect(parseValue('1:00:60', 'duration')).toBeNull() + expect(parseValue('h', 'duration')).toBeNull() }) }) diff --git a/src/lib/resync.ts b/src/lib/resync.ts index 3e60b91..0e0658a 100644 --- a/src/lib/resync.ts +++ b/src/lib/resync.ts @@ -1,6 +1,7 @@ // Resync: correcting a tracked value by hand (decision 003, decision 010). // The tracker stores the typed value as of now and keeps counting from there; this module only // parses, formats and undoes what the user types. +import { formatDuration } from './clock' /** * percent: stock and supplies. money: safes, in dollars. duration: time left, in milliseconds. @@ -28,8 +29,8 @@ export function clampValue(value: number, { min, max }: ValueBounds = {}): numbe } /** - * Text for a value in its unit: "40%", "$120,000", "1:05" (h:mm). Time left is rounded up to the - * minute, so "0:00" only shows once it is done. + * Text for a value in its unit: "40%", "$120,000", "1:05:00" (h:mm:ss, like `Countdown`, so it + * visibly ticks). Time left is rounded up to the second, so "0:00:00" only shows once it is done. */ export function formatValue(value: number, unit: ValueUnit): string { switch (unit) { @@ -37,10 +38,8 @@ export function formatValue(value: number, unit: ValueUnit): string { return `${Math.round(value)}%` case 'money': return `$${Math.round(value).toLocaleString('en-US')}` - case 'duration': { - const minutes = Math.ceil(Math.max(0, value) / MINUTE_MS) - return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}` - } + case 'duration': + return formatDuration(Math.ceil(Math.max(0, value) / 1000) * 1000) case 'crates': // Whole crates made so far; the margin keeps 3.9999... at 4 after float math. return String(Math.floor(value + 1e-6)) @@ -53,7 +52,8 @@ export function formatValue(value: number, unit: ValueUnit): string { * * - percent: "40" or "40%" * - money: "120000", "$120,000", "120k", "1.2m" - * - duration: "1:05" (h:mm) or "45" (minutes); returns milliseconds + * - duration: "21:30" (h:mm), "21:30:15" (h:mm:ss), "5h 30m", "5h", "30m" or "45" (minutes); + * returns milliseconds * - crates: a whole number, e.g. "12" */ export function parseValue(text: string, unit: ValueUnit): number | null { @@ -70,11 +70,16 @@ export function parseValue(text: string, unit: ValueUnit): number | null { return Math.round(amount * factor) } case 'duration': { - const clock = /^(\d+):(\d{1,2})$/.exec(trimmed) + const clock = /^(\d+):(\d{1,2})(?::(\d{1,2}))?$/.exec(trimmed) if (clock) { const minutes = Number(clock[2]) - if (minutes > 59) return null - return (Number(clock[1]) * 60 + minutes) * MINUTE_MS + const seconds = Number(clock[3] ?? 0) + if (minutes > 59 || seconds > 59) return null + return (Number(clock[1]) * 60 + minutes) * MINUTE_MS + seconds * 1000 + } + const units = /^(?:(\d+)\s*h)?\s*(?:(\d+)\s*m(?:in)?)?$/.exec(trimmed) + if (units && (units[1] || units[2])) { + return (Number(units[1] ?? 0) * 60 + Number(units[2] ?? 0)) * MINUTE_MS } const minutes = nonNegative(trimmed) return minutes === null ? null : Math.round(minutes * MINUTE_MS) diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index 30c5e47..8a7f0b1 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: 10, + version: 11, clock: { trackOnOpen: false }, properties: {}, businesses: {}, @@ -33,6 +33,7 @@ describe('parseState', () => { nightclub: {}, safes: {}, boosts: {}, + timers: {}, }, fromVersion: 1, }) @@ -43,7 +44,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 10, + version: 11, clock: { trackOnOpen: true }, properties: {}, businesses: {}, @@ -51,6 +52,7 @@ describe('parseState', () => { nightclub: {}, safes: {}, boosts: {}, + timers: {}, }, fromVersion: 2, }) @@ -64,7 +66,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 10, + version: 11, clock: { trackOnOpen: false }, properties, businesses: {}, @@ -72,6 +74,7 @@ describe('parseState', () => { nightclub: {}, safes: {}, boosts: {}, + timers: {}, }, fromVersion: 3, }) @@ -86,7 +89,7 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 10, + version: 11, clock: { trackOnOpen: true }, properties, businesses, @@ -94,6 +97,7 @@ describe('parseState', () => { nightclub: {}, safes: {}, boosts: {}, + timers: {}, }, fromVersion: 4, }) @@ -110,7 +114,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 10, nightclub: {}, safes: {}, boosts: {} }, + state: { ...sample, version: 11, nightclub: {}, safes: {}, boosts: {}, timers: {} }, fromVersion: 5, }) }) @@ -126,7 +130,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 10, safes: {}, boosts: {} }, + state: { ...sample, version: 11, safes: {}, boosts: {}, timers: {} }, fromVersion: 6, }) }) @@ -143,7 +147,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 10, boosts: {} }, + state: { ...sample, version: 11, boosts: {}, timers: {} }, fromVersion: 7, }) }) @@ -161,7 +165,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 10 }, + state: { ...sample, version: 11, timers: {} }, fromVersion: 8, }) }) @@ -179,11 +183,38 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 10 }, + state: { ...sample, version: 11, timers: {} }, fromVersion: 9, }) }) + it('migrates a version 10 sample, with no timers', () => { + const sample = { + version: 10, + clock: { trackOnOpen: true }, + properties: { 'mc-weed': { owned: true, upgrades: {}, shutDown: true } }, + businesses: { 'mc-weed': { stock: 20, supplies: 60 } }, + businessOrder: [], + nightclub: {}, + safes: {}, + boosts: {}, + } + expect(parseState(JSON.stringify(sample))).toEqual({ + ok: true, + state: { ...sample, version: 11, timers: {} }, + fromVersion: 10, + }) + }) + + it('keeps saved timers', () => { + const timers = { + luckyWheelSpunAt: 5_000, + mansionBoost: { propertyId: 'bunker', startedAt: 7_000 }, + } + const text = JSON.stringify({ ...defaultState(), timers }) + expect(parseState(text)).toMatchObject({ ok: true, state: { timers } }) + }) + it('keeps a saved shut-down flag', () => { const properties = { 'mc-weed': { owned: true, upgrades: {}, shutDown: true } } const text = JSON.stringify({ ...defaultState(), properties }) diff --git a/src/lib/state.ts b/src/lib/state.ts index 3b040d9..41f2f20 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -10,9 +10,10 @@ import { } from './production' import { parseOwnedProperties, type OwnedProperties } from './properties' 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 = 10 +export const CURRENT_VERSION = 11 /** All user state. Trackers add their own sections here as they are built. */ export interface AppState { @@ -31,6 +32,8 @@ export interface AppState { safes: SafesById /** The last production boost of each business, e.g. the Acid Lab's (issue #9). */ boosts: BoostsById + /** Real-time timers: the Lucky Wheel and the Mansion boost (issues #13, #14). */ + timers: Timers } export function defaultState(): AppState { @@ -43,6 +46,7 @@ export function defaultState(): AppState { nightclub: {}, safes: {}, boosts: {}, + timers: {}, } } @@ -73,6 +77,8 @@ export const MIGRATIONS: Readonly> = { // Version 10 adds a shut-down flag per owned business (issue #38); nothing is shut down yet, // but an older LSOS would drop the flag, so it must not read this data. 9: (data) => ({ ...data }), + // Version 11 adds the real-time timers (issues #13, #14); none started yet. + 10: (data) => ({ ...data, timers: {} }), } export type ParseResult = @@ -155,6 +161,8 @@ function toState(data: StoredData): AppState | string { if (typeof safes === 'string') return safes const boosts = parseBoosts(data.boosts) if (typeof boosts === 'string') return boosts + const timers = parseTimers(data.timers) + if (typeof timers === 'string') return timers return { version: CURRENT_VERSION, clock, @@ -164,6 +172,7 @@ function toState(data: StoredData): AppState | string { nightclub, safes, boosts, + timers, } } diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts index 5f334f5..b9935ee 100644 --- a/src/lib/tabs.ts +++ b/src/lib/tabs.ts @@ -2,6 +2,7 @@ export const TABS = [ { id: 'overview', label: 'Overview' }, { id: 'businesses', label: 'Businesses' }, { id: 'safes', label: 'Safes' }, + { id: 'timers', label: 'Timers' }, { id: 'weekly', label: 'Weekly' }, { id: 'settings', label: 'Settings' }, ] as const diff --git a/src/lib/timers.test.ts b/src/lib/timers.test.ts new file mode 100644 index 0000000..f08c794 --- /dev/null +++ b/src/lib/timers.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { PROPERTIES } from '../data/properties' +import { MANSION_BOOST } from '../data/timers' +import { setShutDown, type OwnedProperties } from './properties' +import { + boostableBusinesses, + mansionBoostOn, + parseTimers, + readyAt, + startForRemaining, +} from './timers' + +const HOUR = 60 * 60_000 + +describe('readyAt', () => { + it('is the start plus the cooldown, or long ago if never started', () => { + expect(readyAt(1_000, 24 * HOUR)).toBe(1_000 + 24 * HOUR) + expect(readyAt(undefined, 24 * HOUR)).toBe(-Infinity) + }) +}) + +describe('startForRemaining', () => { + it('puts the start where the time left matches', () => { + expect(startForRemaining(3 * HOUR, 24 * HOUR, 100 * HOUR)).toBe(79 * HOUR) + }) + + it('keeps the time left between none and the whole length', () => { + expect(startForRemaining(30 * HOUR, 24 * HOUR, 100 * HOUR)).toBe(100 * HOUR) + expect(startForRemaining(-5, 24 * HOUR, 100 * HOUR)).toBe(76 * HOUR) + }) +}) + +describe('mansionBoostOn', () => { + const boost = { propertyId: 'bunker', startedAt: 10 * HOUR } + + it('runs for 24 real hours from its start, for the business boosted only', () => { + expect(mansionBoostOn(boost, MANSION_BOOST, 'bunker', 10 * HOUR - 1)).toBe(false) + expect(mansionBoostOn(boost, MANSION_BOOST, 'bunker', 10 * HOUR)).toBe(true) + expect(mansionBoostOn(boost, MANSION_BOOST, 'bunker', 34 * HOUR - 1)).toBe(true) + expect(mansionBoostOn(boost, MANSION_BOOST, 'bunker', 34 * HOUR)).toBe(false) + expect(mansionBoostOn(boost, MANSION_BOOST, 'mc-weed', 11 * HOUR)).toBe(false) + expect(mansionBoostOn(undefined, MANSION_BOOST, 'bunker', 11 * HOUR)).toBe(false) + }) +}) + +describe('boostableBusinesses', () => { + const owned: OwnedProperties = { + bunker: { owned: true, upgrades: {} }, + 'mc-weed': { owned: true, upgrades: {} }, + 'mc-meth': { owned: true, upgrades: {} }, + nightclub: { owned: true, upgrades: {} }, + } + const ids = (owned: OwnedProperties, last?: string) => + boostableBusinesses( + PROPERTIES, + owned, + MANSION_BOOST, + last ? { propertyId: last, startedAt: 0 } : undefined, + ).map((p) => p.id) + + it('lists owned, running businesses the boost covers', () => { + expect(ids(owned)).toEqual(['mc-meth', 'mc-weed', 'bunker']) + expect(ids(setShutDown(owned, 'bunker', true))).toEqual(['mc-meth', 'mc-weed']) + }) + + it('leaves out the business boosted last time', () => { + expect(ids(owned, 'bunker')).toEqual(['mc-meth', 'mc-weed']) + }) +}) + +describe('parseTimers', () => { + it('reads timers, with or without each one', () => { + expect(parseTimers({})).toEqual({}) + const full = { luckyWheelSpunAt: 5, mansionBoost: { propertyId: 'bunker', startedAt: 7 } } + expect(parseTimers(full)).toEqual(full) + }) + + it.each([ + ['not an object', [], 'missing'], + ['a bad spin time', { luckyWheelSpunAt: 'now' }, 'Lucky Wheel'], + ['a boost without a business', { mansionBoost: { startedAt: 7 } }, 'Mansion boost'], + ['a boost without a start', { mansionBoost: { propertyId: 'bunker' } }, 'Mansion boost'], + ])('rejects %s', (_, data, message) => { + expect(parseTimers(data)).toContain(message) + }) +}) diff --git a/src/lib/timers.ts b/src/lib/timers.ts new file mode 100644 index 0000000..bc19e64 --- /dev/null +++ b/src/lib/timers.ts @@ -0,0 +1,94 @@ +// 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 { Property } from '../data/properties' +import { isOwned, isRunning, type OwnedProperties } from './properties' + +export type MansionBoostData = typeof MANSION_BOOST + +/** The last Mansion boost: which business, and when it started (wall clock). */ +export interface MansionBoost { + propertyId: string + startedAt: number +} + +/** Saved real-time timers; a timer is left out until first used. */ +export interface Timers { + /** When the Lucky Wheel was last spun (wall clock). */ + luckyWheelSpunAt?: number + /** The last Mansion boost, kept after it ends for the cooldown and the back-to-back rule. */ + mansionBoost?: MansionBoost +} + +/** When a cooldown that started at `startedAt` is over; long ago if it never started. */ +export function readyAt(startedAt: number | undefined, cooldownMs: number): number { + return startedAt === undefined ? -Infinity : startedAt + cooldownMs +} + +/** + * The start time that leaves `remainingMs` of a `lengthMs` timer at `now`, for correcting a + * countdown by hand (decision 010). + */ +export function startForRemaining(remainingMs: number, lengthMs: number, now: number): number { + return now + Math.min(Math.max(remainingMs, 0), lengthMs) - lengthMs +} + +/** Whether the boost speeds up business `propertyId` at wall-clock time `at`. */ +export function mansionBoostOn( + boost: MansionBoost | undefined, + data: MansionBoostData, + propertyId: string, + at: number, +): boolean { + return ( + !!boost && + boost.propertyId === propertyId && + at >= boost.startedAt && + at < boost.startedAt + data.durationMs + ) +} + +/** + * The businesses the next boost can go to: owned, running, and not the one boosted last time + * (the game does not boost the same business back-to-back). + */ +export function boostableBusinesses( + catalog: readonly Property[], + owned: OwnedProperties, + data: MansionBoostData, + last: MansionBoost | undefined, +): Property[] { + return catalog.filter( + (p) => + (data.propertyIds as readonly string[]).includes(p.id) && + isOwned(owned, p.id) && + isRunning(owned, p.id) && + p.id !== last?.propertyId, + ) +} + +/** 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.' + const result: Timers = {} + if (data.luckyWheelSpunAt !== undefined) { + if (!isTime(data.luckyWheelSpunAt)) return 'The saved Lucky Wheel spin is not readable.' + result.luckyWheelSpunAt = data.luckyWheelSpunAt + } + if (data.mansionBoost !== undefined) { + const boost = data.mansionBoost + if (!isRecord(boost) || typeof boost.propertyId !== 'string' || !isTime(boost.startedAt)) { + return 'The saved Mansion boost is not readable.' + } + result.mansionBoost = { propertyId: boost.propertyId, startedAt: boost.startedAt } + } + return result +} + +function isTime(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +}