From 2dc7fbda9e8691775aeeb0905a17945daf5ed12f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 11:47:26 +0000 Subject: [PATCH 1/3] Add Timers tab: Lucky Wheel cooldown and Mansion production boost A new Timers tab for cooldowns on real time (decision 022): - Lucky Wheel: "Spun" starts a 24-hour countdown to the next spin. - Mansion boost: pick an owned, running business (not the one boosted last time) and press "Activated". It makes stock 3x as fast for 24 real hours, and the next boost is ready 24 hours after the last one started. Business production and forecasts apply the boost. On top of the Acid Lab's own boost the speed is an estimate. Both countdowns can be corrected as time left and have an Undo. The Mansion becomes an owned property. Saved state version 11 adds `timers`. Closes #13 Closes #14 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/022-real-time-timers.md | 33 ++++ docs/decisions/README.md | 1 + src/App.svelte | 10 ++ src/components/BusinessProduction.svelte | 36 +++- src/components/Businesses.svelte | 8 + src/components/Timers.svelte | 217 +++++++++++++++++++++++ src/data/properties.ts | 10 ++ src/data/timers.ts | 42 +++++ src/lib/production.svelte.ts | 4 +- src/lib/production.test.ts | 60 +++++++ src/lib/production.ts | 94 +++++++--- src/lib/state.test.ts | 49 ++++- src/lib/state.ts | 11 +- src/lib/tabs.ts | 1 + src/lib/timers.test.ts | 86 +++++++++ src/lib/timers.ts | 94 ++++++++++ 17 files changed, 723 insertions(+), 34 deletions(-) create mode 100644 docs/decisions/022-real-time-timers.md create mode 100644 src/components/Timers.svelte create mode 100644 src/data/timers.ts create mode 100644 src/lib/timers.test.ts create mode 100644 src/lib/timers.ts 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/022-real-time-timers.md b/docs/decisions/022-real-time-timers.md new file mode 100644 index 0000000..32e634d --- /dev/null +++ b/docs/decisions/022-real-time-timers.md @@ -0,0 +1,33 @@ +# 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). "Undo" takes back the last + button press, including the last boost needed for the back-to-back rule. +- **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..1a88249 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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..a893edc 100644 --- a/src/components/BusinessProduction.svelte +++ b/src/components/BusinessProduction.svelte @@ -1,9 +1,11 @@ @@ -142,6 +159,13 @@ {/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 +191,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. diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte index fb49100..27c7079 100644 --- a/src/components/Businesses.svelte +++ b/src/components/Businesses.svelte @@ -10,6 +10,7 @@ import { NIGHTCLUB_ID, type Goods } from '../data/nightclub' import type { Production } from '../data/production' import type { Property } from '../data/properties' + import { MANSION_BOOST } from '../data/timers' import { applyOrder, cardAt, moveId, saveOrder, swapAt, type CardBox } from '../lib/order' import { emptyBars, @@ -30,6 +31,7 @@ import type { GoodsStock } from '../lib/nightclub' import type { Bars } from '../lib/resync' import { hashForTab } from '../lib/tabs' + import { mansionBoostOn, type MansionBoost } from '../lib/timers' import BusinessProduction from './BusinessProduction.svelte' import NightclubWarehouse from './NightclubWarehouse.svelte' import PropertySettings from './PropertySettings.svelte' @@ -50,6 +52,8 @@ /** The last production boost by property id (issue #9). */ boosts?: BoostsById onboost?: (propertyId: string, boost: Boost | undefined) => void + /** The last Mansion boost (issue #14). */ + mansionBoost?: MansionBoost /** Saved card order: property ids (issue #30). */ order?: readonly string[] onorder?: (order: string[]) => void @@ -69,6 +73,7 @@ onresync = () => {}, boosts = {}, onboost = () => {}, + mansionBoost, order = [], onorder = () => {}, goods = [], @@ -266,6 +271,9 @@ farEstimated={farValueEstimated(production, owned)} boost={boosts[property.id]} onboost={(boost) => onboost(property.id, boost)} + mansionEndsAt={mansionBoostOn(mansionBoost, MANSION_BOOST, property.id, now) + ? mansionBoost!.startedAt + MANSION_BOOST.durationMs + : undefined} /> {/if} {#if warehouse} diff --git a/src/components/Timers.svelte b/src/components/Timers.svelte new file mode 100644 index 0000000..4c62351 --- /dev/null +++ b/src/components/Timers.svelte @@ -0,0 +1,217 @@ + + +

+ + {#if now < spinAt} + + onchange({ + ...timers, + luckyWheelSpunAt: startForRemaining(ms, LUCKY_WHEEL.cooldownMs, now), + })} + /> + {:else} +
+ Spin ready + +
+ {/if} + {#if undo?.timer === 'wheel'} + + {/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} +
+ Boosting ×{MANSION_BOOST.speed} + {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} +
+ + + +
+ {:else} +

+ Boost ready, but no business you own and run can take it{last + ? ` (${label(last.propertyId)} was boosted last time)` + : ''}. +

+ {/if} + {/if} + {#if undo?.timer === 'mansion'} + + {/if} +

+ Production ×{MANSION_BOOST.speed} at one of the Bunker, Acid Lab or MC businesses for 24 real + hours. The next boost can start 24 real hours after the last one, on a different business. +

+ {/if} +
+
+ + 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/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) +} From f35d9ce6cd8be5483837beb8a29341a08c37f293 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 11:51:47 +0000 Subject: [PATCH 2/3] Share the fact row and dropdown as ui/Fact and ui/Select The label-and-value row was copied into five cards (business, safe, Money Fronts, nightclub warehouse, now Timers), each with its own CSS. It is now one base component, `Fact`. The upgrade dropdown in Settings and the business pick on the Timers tab share `Select`. Both are in the component kit. Timers now follows the Acid Lab boost layout: "Boost ready" with a plain button, and Undo in the row of what it takes back. Co-Authored-By: Claude Opus 5.5 --- src/components/BusinessProduction.svelte | 65 ++++++------------ src/components/MoneyFrontsCard.svelte | 25 +------ src/components/NightclubWarehouse.svelte | 25 +------ src/components/PropertySettings.svelte | 31 ++------- src/components/SafeCard.svelte | 25 +------ src/components/Timers.svelte | 87 +++++++----------------- src/components/ui/Fact.svelte | 53 +++++++++++++++ src/components/ui/Kit.svelte | 19 ++++++ src/components/ui/Select.svelte | 40 +++++++++++ 9 files changed, 170 insertions(+), 200 deletions(-) create mode 100644 src/components/ui/Fact.svelte create mode 100644 src/components/ui/Select.svelte diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte index a893edc..89f22a1 100644 --- a/src/components/BusinessProduction.svelte +++ b/src/components/BusinessProduction.svelte @@ -21,6 +21,7 @@ import BusinessBars from './ui/BusinessBars.svelte' import Button from './ui/Button.svelte' import Countdown from './ui/Countdown.svelte' + import Fact from './ui/Fact.svelte' import Meter from './ui/Meter.svelte' interface Props { @@ -123,37 +124,35 @@ {/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} @@ -210,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 index 4c62351..2d7c8fe 100644 --- a/src/components/Timers.svelte +++ b/src/components/Timers.svelte @@ -16,7 +16,9 @@ import Button from './ui/Button.svelte' import Countdown from './ui/Countdown.svelte' import EditableValue from './ui/EditableValue.svelte' + import Fact from './ui/Fact.svelte' import Panel from './ui/Panel.svelte' + import Select from './ui/Select.svelte' interface Props { catalog: readonly Property[] @@ -64,6 +66,11 @@
{#if now < spinAt} + {#if undo?.timer === 'wheel'} + + + + {/if} {:else} -
- Spin ready - -
- {/if} - {#if undo?.timer === 'wheel'} - + + + {/if}

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

@@ -99,10 +99,12 @@

{:else} {#if boostOn} -
- Boosting ×{MANSION_BOOST.speed} - {label(last!.propertyId)} -
+ + {label(last!.propertyId)} + {#if undo?.timer === 'mansion'} + + {/if} + {:else if now >= nextBoostAt} {#if choices.length > 0} -
- - (chosen = event.currentTarget.value)} - > - {#each choices as property (property.id)} - - {/each} - + options={choices} + onchange={(id) => (chosen = id)} + /> + + -
+ {:else}

Boost ready, but no business you own and run can take it{last @@ -152,9 +151,6 @@

{/if} {/if} - {#if undo?.timer === 'mansion'} - - {/if}

Production ×{MANSION_BOOST.speed} at one of the Bunker, Acid Lab or MC businesses for 24 real hours. The next boost can start 24 real hours after the last one, on a different business. @@ -170,41 +166,6 @@ grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); } - .facts { - align-items: center; - display: flex; - flex-wrap: wrap; - 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; - } - - .label.ready { - color: var(--accent-text); - } - - .value { - text-align: right; - } - - select { - background: var(--panel-raised); - border: 1px solid var(--line); - border-radius: var(--radius); - color: var(--fg); - flex: 1; - font: inherit; - min-height: 2.25rem; - padding: 0 var(--space-2); - } - .hint { color: var(--muted); font-size: var(--text-sm); diff --git a/src/components/ui/Fact.svelte b/src/components/ui/Fact.svelte new file mode 100644 index 0000000..97182cc --- /dev/null +++ b/src/components/ui/Fact.svelte @@ -0,0 +1,53 @@ + + +

+ {#if labelFor} + + {:else} + {label} + {/if} + {@render children()} +
+ + diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index f49f74f..695e54b 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -12,11 +12,14 @@ import Button from './Button.svelte' import Countdown from './Countdown.svelte' import EditableValue from './EditableValue.svelte' + import Fact from './Fact.svelte' import Meter from './Meter.svelte' import NumberField from './NumberField.svelte' import Panel from './Panel.svelte' + import Select from './Select.svelte' let amount = $state(40) + let picked = $state('a') let safe = $state(70_000) let bars = $state({ stock: 60, supplies: 35 }) let stockOnly = $state({ stock: 45 }) @@ -93,6 +96,22 @@ + + $120,000 + + + onchange(event.currentTarget.value)}> + {#each options as option (option.id)} + + {/each} + + + From 93caadd3767bd9705ff2be816969857150b5d498 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 12:01:24 +0000 Subject: [PATCH 3/3] Timers: ticking h:mm:ss countdowns, Undo in the card footer - Editable time left shows h:mm:ss like every Countdown, so it visibly runs. It reads h:mm, h:mm:ss, "5h 30m", "5h", "30m" or plain minutes, and the hint gives an example with two-digit hours. - Mansion card: a plain "Boosting: " row. Undo for a spin or a boost moves to the card's footer strip, and each card keeps its own. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/010-resync.md | 2 +- docs/decisions/022-real-time-timers.md | 7 ++- docs/decisions/README.md | 2 +- src/components/Timers.svelte | 76 +++++++++++++------------- src/components/ui/EditableValue.svelte | 2 +- src/lib/resync.test.ts | 26 ++++++--- src/lib/resync.ts | 25 +++++---- 7 files changed, 78 insertions(+), 62 deletions(-) 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 index 32e634d..a82346a 100644 --- a/docs/decisions/022-real-time-timers.md +++ b/docs/decisions/022-real-time-timers.md @@ -19,8 +19,11 @@ 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). "Undo" takes back the last - button press, including the last boost needed for the back-to-back rule. +- 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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1a88249..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 diff --git a/src/components/Timers.svelte b/src/components/Timers.svelte index 2d7c8fe..0b1e7ba 100644 --- a/src/components/Timers.svelte +++ b/src/components/Timers.svelte @@ -11,6 +11,7 @@ mansionBoostOn, readyAt, startForRemaining, + type MansionBoost, type Timers, } from '../lib/timers' import Button from './ui/Button.svelte' @@ -31,23 +32,18 @@ let { catalog, owned, timers, now, onchange }: Props = $props() - // What the last button press replaced, so a press by mistake can be taken back. - let undo = $state<{ timer: 'wheel' | 'mansion'; before: Timers } | null>(null) + // What each timer's last button press replaced, so a press by mistake can be taken back. + let undoSpin = $state<{ before: number | undefined } | null>(null) + let undoBoost = $state<{ before: MansionBoost | undefined } | null>(null) - function press(timer: 'wheel' | 'mansion', next: Timers) { - undo = { timer, before: timers } - onchange(next) + function spin() { + undoSpin = { before: timers.luckyWheelSpunAt } + onchange({ ...timers, luckyWheelSpunAt: now }) } - function takeBack() { - if (!undo) return - const { before, timer } = undo - onchange( - timer === 'wheel' - ? { ...timers, luckyWheelSpunAt: before.luckyWheelSpunAt } - : { ...timers, mansionBoost: before.mansionBoost }, - ) - undo = null + function activate(propertyId: string) { + undoBoost = { before: timers.mansionBoost } + onchange({ ...timers, mansionBoost: { propertyId, startedAt: now } }) } const spinAt = $derived(readyAt(timers.luckyWheelSpunAt, LUCKY_WHEEL.cooldownMs)) @@ -63,14 +59,30 @@ const target = $derived(choices.some((p) => p.id === chosen) ? chosen : choices[0]?.id) + +{#snippet wheelUndo()} + +{/snippet} + +{#snippet mansionUndo()} + +{/snippet} +
- + {#if now < spinAt} - {#if undo?.timer === 'wheel'} - - - - {/if} {: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 @@ -99,12 +111,7 @@

{:else} {#if boostOn} - - {label(last!.propertyId)} - {#if undo?.timer === 'mansion'} - - {/if} - + {label(last!.propertyId)} - + {:else}

@@ -152,8 +152,8 @@ {/if} {/if}

- Production ×{MANSION_BOOST.speed} at one of the Bunker, Acid Lab or MC businesses for 24 real - hours. The next boost can start 24 real hours after the last one, on a different business. + Production ×{MANSION_BOOST.speed} at one Bunker, Acid Lab or MC business for 24 real hours. The + next boost is ready 24 real hours after the last one, for a different business.

{/if}
diff --git a/src/components/ui/EditableValue.svelte b/src/components/ui/EditableValue.svelte index c83653d..2c19afc 100644 --- a/src/components/ui/EditableValue.svelte +++ b/src/components/ui/EditableValue.svelte @@ -50,7 +50,7 @@ const HINTS: Record = { percent: 'A percentage, e.g. 40', money: 'An amount, e.g. 120,000 or 120k', - duration: 'Time left as h:mm, e.g. 1:05', + duration: 'Time left, e.g. 21:30 (hours:minutes) or 5h 30m', crates: 'Whole crates, e.g. 12', } 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)