diff --git a/CLAUDE.md b/CLAUDE.md index 45fb74b..4476e0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Game data with sources | `src/data/` (decision 004) | | Owned properties and upgrades | `data/properties.ts`, `lib/properties.ts` (011) | | Business production (stock, supplies, value) | `data/production.ts`, `lib/production.ts` (012) | +| Nightclub warehouse goods | `data/nightclub.ts`, `lib/nightclub.ts` (015) | | Business card order (drag, keys) | `lib/order.ts`, `ui/Panel` `move` (decision 014) | | Theme tokens | `src/app.css` (decision 009) | | CI, templates | `.github/` | diff --git a/docs/decisions/015-nightclub-warehouse.md b/docs/decisions/015-nightclub-warehouse.md new file mode 100644 index 0000000..5e0f600 --- /dev/null +++ b/docs/decisions/015-nightclub-warehouse.md @@ -0,0 +1,43 @@ +# 015: Nightclub warehouse goods + +2026-10-14, issue #7. Builds on decisions 004, 011, 012 and 013. + +## Decision + +- **Data** (`src/data/nightclub.ts`): the seven goods types with value per crate, crates per + warehouse floor and minutes per crate without and with the Equipment Upgrade, from gta.wiki + (Nightclubs/Warehouse Management). Each lists the properties that unlock it; Cargo and + Shipments is unlocked by the Hangar or the Special Cargo Warehouse, which join the catalog as + owned/not owned only, with no card on the Businesses tab (`card: false`). +- **Settings:** the Nightclub property has Equipment (toggle), Warehouse floors (choice 1 to 5) + and five technician choices ("Not assigned"/"Not hired", then each goods type). Staff and + security upgrades do not change goods: left out. +- **Only ownership is needed.** The source says technicians need no supplies, so goods do not + depend on the linked business's stock or supplies. Goods whose business is not owned do not + run and the card says "Locked". Two technicians on one type count once (the game allows one). + A shut-down business locks its goods too; marking one shut down is issue #38. +- **Saved state** (version 6): `nightclub` maps a goods id to crates, fractions included. Crates, + not percent, so changing the floors keeps the count the game shows. Stock above the capacity + (fewer floors set) stays until changed. +- **Production is continuous** on play time, like decision 012; count and value use whole crates. +- **Every floor holds the same:** the source gives 1 and 5 floors (exactly five times as much); + 2 to 4 floors were confirmed in game (issue #35). The source's "each subsequent floor doubles + that storage capacity" contradicts its own table and is not used. +- **UI:** collapsed, the card shows two lines per goods type that is being made or has stock: + name with crates and value, then the bar with its play time to full under one "Full in" + header ("Full", or "Idle" when no technician makes it). The next to fill gets an accent edge. + A total value follows. Technicians are left out of the card's upgrade summary, as the rows + show them. "Edit" lists all seven types as `EditableValue` fields in crates, with Sold and + Full, so goods from Business Battles can be entered too. +- **Best setup:** goods are ranked by value per hour of play time (value per crate over time + per crate, the source's own "value per hour" column). The card suggests putting the available + technicians (Yohan, plus those hired; it never hires) on the best unlocked goods that are not + full, keeping technicians already on one of them, and shows the gain per hour with an Apply + button ("Move"). Full goods earn nothing, so a full type frees its technician: the card says + " is full. Sell it, or move technician N to ", or only "Sell it" when there is + nothing better to move to. + +## Why + +Crates match what the game shows per goods type and survive a change of floors. Listing the +unlocking properties keeps the rule ("owns the business") in data with its source. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 64b2b0c..7b64313 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -25,3 +25,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [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 - [014](014-card-order.md) Business cards: drag a title bar to lift a card and swap it with another, or move it with arrow keys; order saved as property ids +- [015](015-nightclub-warehouse.md) Nightclub warehouse: goods per technician on play time, saved in crates, unlocked by owning the business, best technician setup by value per hour diff --git a/src/App.svelte b/src/App.svelte index 42c244d..4d07500 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,6 +5,7 @@ import SaveStatus from './components/SaveStatus.svelte' import Settings from './components/Settings.svelte' import TabBar from './components/ui/TabBar.svelte' + import { GOODS } from './data/nightclub' import { PRODUCTION } from './data/production' import { PROPERTIES } from './data/properties' import { clocks } from './lib/clock.svelte' @@ -55,6 +56,10 @@ (store.state.businesses = { ...store.state.businesses, [id]: bars })} order={store.state.businessOrder} onorder={(order) => (store.state.businessOrder = order)} + goods={GOODS} + nightclubStock={store.state.nightclub} + onnightclub={(id, crates) => + (store.state.nightclub = { ...store.state.nightclub, [id]: crates })} /> {:else if active === 'settings'} diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte index 4a8b0b5..246ca4d 100644 --- a/src/components/Businesses.svelte +++ b/src/components/Businesses.svelte @@ -1,19 +1,23 @@ + +{#if editing} + {#each goods as g (g.id)} + {@const cap = capacity(g, setup.floors)} + onresync(g.id, value)} + > + + + {/each} +{:else if shown.length === 0} +

No technician is making goods. Assign one in this card's settings.

+{:else} + + + {#each shown as g (g.id)} + {@const cap = capacity(g, setup.floors)} + {@const ms = fullInMs(g, crates(g.id), setup)} + {@const producing = isProducing(g, setup, owned)} +
+
+ {g.label} + {wholeCrates(crates(g.id))} / {cap} · {formatValue( + goodsValue(g, crates(g.id)), + 'money', + )} +
+
+ + {#if !producing} + Idle + {:else if ms <= 0} + Full + {:else} + + {/if} +
+
+ {/each} +{/if} + +
+ Total value + {formatValue(total, 'money')} +
+ +{#each fullStuck as id (id)} +

+ {label(id)} is full. Sell it: its technician makes nothing until then. +

+{/each} + +{#if suggestion} +
+

+ More per hour: + {#each suggestion.moves as move (move.technician)} + {#if full.includes(move.from)} + {label(move.from)} is full. Sell it, or move technician {move.technician} to + {label(move.to)}. + {:else} + Technician {move.technician}: {label(move.to)} + (now {label(move.from)}) + {/if} + {/each} +

+
+ +{formatValue(suggestion.gainPerHour, 'money')} / h + +
+
+{/if} + +{#if locked.length > 0} +

+ Locked. + {locked.map((g) => g.label).join(', ')}: own the business that makes + {locked.length === 1 ? 'it' : 'them'} to start production. +

+{/if} +{#if setup.doubled} +

+ Two technicians on one category. The game allows one technician per category, so + only one counts. +

+{/if} + + diff --git a/src/components/PropertySettings.svelte b/src/components/PropertySettings.svelte index 564df54..e670f6e 100644 --- a/src/components/PropertySettings.svelte +++ b/src/components/PropertySettings.svelte @@ -95,21 +95,23 @@ text-transform: uppercase; } + /* Labels in one column and selects in the other, so every choice lines up. */ .upgrades { + align-items: center; border-left: 2px solid var(--line); - display: flex; - flex-direction: column; - gap: var(--space-2); + display: grid; + gap: var(--space-2) var(--space-3); + grid-template-columns: auto minmax(0, 1fr); margin-left: 0.5rem; padding-left: var(--space-3); } + .upgrades > label { + grid-column: 1 / -1; + } + .choice { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - justify-content: space-between; + display: contents; } input[type='checkbox'] { @@ -126,7 +128,9 @@ color: var(--fg); font: inherit; min-height: 2.25rem; + min-width: 0; padding: var(--space-1) var(--space-2); + width: 100%; } select:hover { diff --git a/src/components/ui/EditableValue.svelte b/src/components/ui/EditableValue.svelte index 978888c..cb846dd 100644 --- a/src/components/ui/EditableValue.svelte +++ b/src/components/ui/EditableValue.svelte @@ -49,6 +49,7 @@ 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', + crates: 'Whole crates, e.g. 12', } const id = $props.id() @@ -113,7 +114,7 @@ aria-invalid={invalid} aria-describedby="{id}-hint" autocomplete="off" - inputmode={unit === 'duration' ? 'text' : 'decimal'} + inputmode={unit === 'duration' ? 'text' : unit === 'crates' ? 'numeric' : 'decimal'} oninput={() => (invalid = false)} onkeydown={(event) => { if (event.key === 'Escape') stopEditing() diff --git a/src/data/nightclub.ts b/src/data/nightclub.ts new file mode 100644 index 0000000..1dae0af --- /dev/null +++ b/src/data/nightclub.ts @@ -0,0 +1,89 @@ +// Nightclub warehouse goods: what technicians make, how fast, how much fits and what it is worth +// (decision 004, decision 015). + +/** One goods category a technician can be assigned to. */ +export interface Goods { + /** Stable id, saved in user data (technician assignments, stock): never change or reuse it. */ + id: string + label: string + /** Owning any of these properties unlocks the category; nothing else of theirs is needed. */ + requires: readonly string[] + /** Sale value of one crate, in dollars. */ + crateValue: number + /** Crates each warehouse floor holds: 1 floor holds this many, 5 floors five times as many. */ + cratesPerFloor: number + /** Play time per crate without and with the Equipment Upgrade. */ + minutesPerCrate: { base: number; equipment: number } +} + +export const NIGHTCLUB_ID = 'nightclub' +export const MAX_FLOORS = 5 +export const MAX_TECHNICIANS = 5 + +export const NIGHTCLUB_SOURCE = + 'gta.wiki, Nightclubs/Warehouse Management: goods per business, technicians (one per goods ' + + 'type, Yohan free, four more hired), value per crate (current, after the April 2022 increase ' + + 'for MC goods), storage with 1 and 5 floors, production per crate with and without the ' + + 'Equipment Upgrade; "Unlike running those businesses themselves, no supplies will need to ' + + 'be sourced or purchased": only owning the linked business is needed. Storage with 2 to 4 ' + + 'floors (the same per floor) confirmed in game, issue #35.' +export const NIGHTCLUB_CHECKED = '2026-10-14' + +export const GOODS: readonly Goods[] = [ + { + id: 'cargo', + label: 'Cargo and Shipments', + requires: ['special-cargo', 'hangar'], + crateValue: 10_000, + cratesPerFloor: 10, + minutesPerCrate: { base: 140, equipment: 70 }, + }, + { + id: 'sporting', + label: 'Sporting Goods', + requires: ['bunker'], + crateValue: 5_000, + cratesPerFloor: 20, + minutesPerCrate: { base: 80, equipment: 40 }, + }, + { + id: 'imports', + label: 'South American Imports', + requires: ['mc-cocaine'], + crateValue: 27_000, + cratesPerFloor: 2, + minutesPerCrate: { base: 240, equipment: 120 }, + }, + { + id: 'pharma', + label: 'Pharmaceutical Research', + requires: ['mc-meth'], + crateValue: 11_475, + cratesPerFloor: 4, + minutesPerCrate: { base: 120, equipment: 60 }, + }, + { + id: 'organic', + label: 'Organic Produce', + requires: ['mc-weed'], + crateValue: 2_025, + cratesPerFloor: 16, + minutesPerCrate: { base: 40, equipment: 20 }, + }, + { + id: 'printing', + label: 'Printing and Copying', + requires: ['mc-documents'], + crateValue: 1_350, + cratesPerFloor: 12, + minutesPerCrate: { base: 30, equipment: 15 }, + }, + { + id: 'cash', + label: 'Cash Creation', + requires: ['mc-cash'], + crateValue: 4_725, + cratesPerFloor: 8, + minutesPerCrate: { base: 60, equipment: 30 }, + }, +] diff --git a/src/data/properties.ts b/src/data/properties.ts index dbb46e4..2e8efa9 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -11,17 +11,34 @@ export type Upgrade = /** Bought or not, e.g. an equipment upgrade. */ | { kind: 'toggle'; id: string; label: string } /** One of several options; the first one is the default, e.g. "Not hired". */ - | { kind: 'choice'; id: string; label: string; options: readonly UpgradeOption[] } + | { + kind: 'choice' + id: string + label: string + options: readonly UpgradeOption[] + /** False to leave it out of the card's upgrade summary, when the card shows it anyway. */ + summary?: false + } export interface Property { /** Stable id, saved in user data: never change or reuse it. */ id: string label: string upgrades: readonly Upgrade[] + /** False for properties that only unlock something elsewhere: no card on the Businesses tab. */ + card?: false source: string checked: string } +import { + GOODS, + MAX_FLOORS, + MAX_TECHNICIANS, + NIGHTCLUB_CHECKED, + NIGHTCLUB_SOURCE, +} from './nightclub' + // 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. const PRODUCTION_UPGRADES: readonly Upgrade[] = [ @@ -46,6 +63,54 @@ export const PROPERTIES: readonly Property[] = [ source: BUNKER_SOURCE, checked: '2026-10-14', }, + // Only owned or not: owning either unlocks Cargo and Shipments in the nightclub (issue #7). + { + id: 'special-cargo', + label: 'Special Cargo Warehouse', + upgrades: [], + card: false, + source: NIGHTCLUB_SOURCE, + checked: NIGHTCLUB_CHECKED, + }, + { + id: 'hangar', + label: 'Hangar', + upgrades: [], + card: false, + source: NIGHTCLUB_SOURCE, + checked: NIGHTCLUB_CHECKED, + }, + { + id: 'nightclub', + label: 'Nightclub', + // Staff and security upgrades only change popularity and raids: left out. + upgrades: [ + { kind: 'toggle', id: 'equipment', label: 'Equipment' }, + { + kind: 'choice', + id: 'floors', + label: 'Warehouse floors', + options: Array.from({ length: MAX_FLOORS }, (_, i) => ({ + id: String(i + 1), + label: String(i + 1), + })), + }, + ...Array.from({ length: MAX_TECHNICIANS }, (_, i): Upgrade => ({ + kind: 'choice', + id: `technician-${i + 1}`, + // Yohan comes with the club; the others are hired. + label: i === 0 ? 'Technician 1 (Yohan)' : `Technician ${i + 1}`, + // The warehouse rows already show what is being made. + summary: false, + options: [ + { id: 'none', label: i === 0 ? 'Not assigned' : 'Not hired' }, + ...GOODS.map((goods) => ({ id: goods.id, label: goods.label })), + ], + })), + ], + source: NIGHTCLUB_SOURCE, + checked: NIGHTCLUB_CHECKED, + }, ] function mc(id: string, label: string): Property { diff --git a/src/lib/nightclub.test.ts b/src/lib/nightclub.test.ts new file mode 100644 index 0000000..a14f0c1 --- /dev/null +++ b/src/lib/nightclub.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { GOODS, NIGHTCLUB_ID } from '../data/nightclub' +import { PROPERTIES } from '../data/properties' +import { + capacity, + fullGoods, + fullInMs, + goodsValue, + nextToFill, + parseGoodsStock, + produceGoods, + suggestTechnicians, + valuePerHour, + warehouseSetup, + wholeCrates, +} from './nightclub' +import type { OwnedProperties, UpgradeValue } from './properties' + +const MIN = 60_000 +const nightclub = PROPERTIES.find((p) => p.id === NIGHTCLUB_ID)! +const goods = (id: string) => GOODS.find((g) => g.id === id)! + +/** Owns the nightclub with `upgrades` and every property in `also`. */ +function owning(upgrades: Record, also: string[] = []): OwnedProperties { + const owned: OwnedProperties = { [NIGHTCLUB_ID]: { owned: true, upgrades } } + for (const id of also) owned[id] = { owned: true, upgrades: {} } + return owned +} + +describe('data', () => { + it('unlocks every goods type with properties in the catalog', () => { + for (const g of GOODS) { + for (const id of g.requires) expect(PROPERTIES.some((p) => p.id === id)).toBe(true) + } + }) + + it('offers every goods type to each technician', () => { + const techs = nightclub.upgrades.filter((u) => u.id.startsWith('technician-')) + expect(techs).toHaveLength(5) + for (const tech of techs) { + expect(tech.kind === 'choice' && tech.options.map((o) => o.id)).toEqual([ + 'none', + ...GOODS.map((g) => g.id), + ]) + } + }) +}) + +describe('warehouseSetup', () => { + it('defaults to one floor, no equipment, nothing assigned', () => { + expect(warehouseSetup(owning({}), nightclub)).toEqual({ + floors: 1, + equipment: false, + assigned: [], + doubled: false, + }) + }) + + it('counts a goods type once when two technicians are on it', () => { + const owned = owning({ + 'technician-1': 'cash', + 'technician-3': 'cash', + 'technician-2': 'cargo', + }) + expect(warehouseSetup(owned, nightclub)).toMatchObject({ + assigned: ['cash', 'cargo'], + doubled: true, + }) + }) +}) + +describe('capacity', () => { + it('grows by the same amount per floor', () => { + expect(capacity(goods('cargo'), 1)).toBe(10) + expect(capacity(goods('cargo'), 3)).toBe(30) + expect(capacity(goods('cargo'), 5)).toBe(50) + }) +}) + +describe('produceGoods', () => { + const owned = owning({ 'technician-1': 'cash', floors: '5', equipment: true }, ['mc-cash']) + + it('makes one Cash Creation crate per 30 minutes with equipment', () => { + const after = produceGoods({}, GOODS, PROPERTIES, owned, 90 * MIN) + expect(after).toEqual({ cash: 3 }) + }) + + it('takes twice as long without equipment', () => { + const slow = owning({ 'technician-1': 'cash' }, ['mc-cash']) + expect(produceGoods({}, GOODS, PROPERTIES, slow, 90 * MIN)).toEqual({ cash: 1.5 }) + }) + + it('stops at the capacity', () => { + expect(produceGoods({ cash: 39 }, GOODS, PROPERTIES, owned, 120 * MIN)).toEqual({ cash: 40 }) + }) + + it('keeps stock above the capacity as it is', () => { + const stock = { cash: 12 } + const oneFloor = owning({ 'technician-1': 'cash' }, ['mc-cash']) + expect(produceGoods(stock, GOODS, PROPERTIES, oneFloor, 60 * MIN)).toBe(stock) + }) + + it('makes nothing without the linked business or the nightclub', () => { + const stock = {} + const noCash = owning({ 'technician-1': 'cash' }) + expect(produceGoods(stock, GOODS, PROPERTIES, noCash, 60 * MIN)).toBe(stock) + const sold = { ...owned, [NIGHTCLUB_ID]: { ...owned[NIGHTCLUB_ID]!, owned: false } } + expect(produceGoods(stock, GOODS, PROPERTIES, sold, 60 * MIN)).toBe(stock) + }) + + it('unlocks cargo with either the hangar or the special cargo warehouse', () => { + const viaHangar = owning({ 'technician-1': 'cargo' }, ['hangar']) + expect(produceGoods({}, GOODS, PROPERTIES, viaHangar, 140 * MIN)).toEqual({ cargo: 1 }) + const viaWarehouse = owning({ 'technician-1': 'cargo' }, ['special-cargo']) + expect(produceGoods({}, GOODS, PROPERTIES, viaWarehouse, 140 * MIN)).toEqual({ cargo: 1 }) + }) +}) + +describe('forecast and value', () => { + const owned = owning( + { 'technician-1': 'cash', 'technician-2': 'imports', floors: '5', equipment: true }, + ['mc-cash', 'mc-cocaine'], + ) + const setup = warehouseSetup(owned, nightclub) + + it('gives the play time until full', () => { + // 40 crates at 30 minutes: 20 hours, as the source says. + expect(fullInMs(goods('cash'), 0, setup)).toBe(20 * 60 * MIN) + expect(fullInMs(goods('cash'), 40, setup)).toBe(0) + }) + + it('picks the producing goods that fill first and are not full', () => { + expect(nextToFill(GOODS, { cash: 39, imports: 0 }, setup, owned)).toBe('cash') + expect(nextToFill(GOODS, { cash: 40, imports: 0 }, setup, owned)).toBe('imports') + expect(nextToFill(GOODS, { cash: 40, imports: 10 }, setup, owned)).toBeUndefined() + }) + + it('lists the producing goods that are full', () => { + expect(fullGoods(GOODS, { cash: 40, imports: 3, organic: 80 }, setup, owned)).toEqual(['cash']) + }) + + it('values whole crates only', () => { + expect(wholeCrates(3.9999999)).toBe(4) + expect(goodsValue(goods('imports'), 2.5)).toBe(54_000) + }) +}) + +describe('parseGoodsStock', () => { + it('keeps any id with a non-negative number', () => { + expect(parseGoodsStock({ cash: 3.5, future: 1 })).toEqual({ cash: 3.5, future: 1 }) + }) + + it('rejects a bad shape', () => { + expect(parseGoodsStock(null)).toBeTypeOf('string') + expect(parseGoodsStock({ cash: -1 })).toBeTypeOf('string') + expect(parseGoodsStock({ cash: '3' })).toBeTypeOf('string') + }) +}) + +describe('valuePerHour', () => { + it('matches the per-hour values the source lists with equipment', () => { + expect(valuePerHour(goods('imports'), true)).toBe(13_500) + expect(valuePerHour(goods('cash'), true)).toBe(9_450) + expect(Math.floor(valuePerHour(goods('cargo'), true))).toBe(8_571) + }) +}) + +describe('suggestTechnicians', () => { + const all = ['mc-cash', 'mc-cocaine', 'mc-meth', 'mc-weed', 'mc-documents', 'bunker', 'hangar'] + + it('moves technicians to the best goods the user owns, keeping the ones already there', () => { + const owned = owning( + { + equipment: true, + 'technician-1': 'cash', + 'technician-2': 'organic', + 'technician-3': 'printing', + }, + all, + ) + const suggestion = suggestTechnicians(GOODS, {}, owned, nightclub)! + // Best three: imports 13,500/h, pharma 11,475/h, cash 9,450/h. Cash stays with Yohan. + expect(suggestion.picks).toEqual(['cash', 'imports', 'pharma', 'none', 'none']) + expect(suggestion.moves).toEqual([ + { technician: 2, from: 'organic', to: 'imports' }, + { technician: 3, from: 'printing', to: 'pharma' }, + ]) + expect(suggestion.gainPerHour).toBe(13_500 + 11_475 - 6_075 - 5_400) + }) + + it('skips goods that are full or locked and never hires', () => { + const owned = owning({ 'technician-1': 'organic' }, ['mc-weed', 'mc-cash', 'mc-cocaine']) + const suggestion = suggestTechnicians(GOODS, { imports: 2 }, owned, nightclub)! + expect(suggestion.picks).toEqual(['cash', 'none', 'none', 'none', 'none']) + }) + + it('has nothing to suggest once the setup is the best', () => { + const owned = owning({ 'technician-1': 'cash', 'technician-2': 'imports' }, all) + // Imports stays with technician 2; Yohan moves from cash to pharma. + expect(suggestTechnicians(GOODS, {}, owned, nightclub)?.picks).toEqual([ + 'pharma', + 'imports', + 'none', + 'none', + 'none', + ]) + const best = owning({ 'technician-1': 'pharma', 'technician-2': 'imports' }, all) + expect(suggestTechnicians(GOODS, {}, best, nightclub)).toBeUndefined() + }) +}) diff --git a/src/lib/nightclub.ts b/src/lib/nightclub.ts new file mode 100644 index 0000000..8b057d1 --- /dev/null +++ b/src/lib/nightclub.ts @@ -0,0 +1,221 @@ +// Nightclub warehouse: goods rise on play time per assigned technician (issue #7, decision 015). +// Pure logic: play time comes in as an argument (decision 003). +import { MAX_TECHNICIANS, NIGHTCLUB_ID, type Goods } from '../data/nightclub' +import type { Property } from '../data/properties' +import { isOwned, upgradeValue, type OwnedProperties } from './properties' + +const MINUTE_MS = 60_000 + +/** Crates in stock by goods id, as of the last play time counted. Fractions are crates in progress. */ +export type GoodsStock = Record + +/** What the nightclub settings mean for its warehouse. */ +export interface WarehouseSetup { + floors: number + equipment: boolean + /** Goods ids with a technician, in technician order, each once. */ + assigned: string[] + /** Whether two technicians are on the same goods: only one of them counts. */ + doubled: boolean +} + +export function warehouseSetup(owned: OwnedProperties, nightclub: Property): WarehouseSetup { + const value = (id: string) => { + const upgrade = nightclub.upgrades.find((u) => u.id === id) + return upgrade ? upgradeValue(owned, nightclub.id, upgrade) : undefined + } + const picks = technicianPicks(owned, nightclub).filter((pick) => pick !== 'none') + const assigned = [...new Set(picks)] + return { + floors: Number(value('floors')) || 1, + equipment: value('equipment') === true, + assigned, + doubled: assigned.length < picks.length, + } +} + +/** What each technician is on, `technician-1` first: a goods id, or 'none'. */ +export function technicianPicks(owned: OwnedProperties, nightclub: Property): string[] { + return Array.from({ length: MAX_TECHNICIANS }, (_, i) => { + const upgrade = nightclub.upgrades.find((u) => u.id === `technician-${i + 1}`) + const value = upgrade ? upgradeValue(owned, nightclub.id, upgrade) : 'none' + return typeof value === 'string' ? value : 'none' + }) +} + +/** Crates that fit with this many warehouse floors. */ +export function capacity(goods: Goods, floors: number): number { + return goods.cratesPerFloor * floors +} + +export function msPerCrate(goods: Goods, equipment: boolean): number { + return (equipment ? goods.minutesPerCrate.equipment : goods.minutesPerCrate.base) * MINUTE_MS +} + +/** Whether the user owns a business that unlocks these goods. */ +export function isUnlocked(goods: Goods, owned: OwnedProperties): boolean { + return goods.requires.some((id) => isOwned(owned, id)) +} + +/** Whether these goods are being made: a technician is on them and they are unlocked. */ +export function isProducing(goods: Goods, setup: WarehouseSetup, owned: OwnedProperties): boolean { + return setup.assigned.includes(goods.id) && isUnlocked(goods, owned) +} + +/** Whole crates: the game sells whole crates only. */ +export function wholeCrates(stock: number): number { + // The small margin keeps 3.9999... crates at 4 after float math. + return Math.floor(stock + 1e-6) +} + +/** Play time until these goods are full; 0 when full. */ +export function fullInMs(goods: Goods, stock: number, setup: WarehouseSetup): number { + const room = capacity(goods, setup.floors) - stock + return Math.max(0, room) * msPerCrate(goods, setup.equipment) +} + +/** Sale value of the whole crates in stock. */ +export function goodsValue(goods: Goods, stock: number): number { + return wholeCrates(stock) * goods.crateValue +} + +/** Goods a technician is on that are full: the technician makes nothing until they are sold. */ +export function fullGoods( + catalog: readonly Goods[], + stock: GoodsStock, + setup: WarehouseSetup, + owned: OwnedProperties, +): string[] { + return catalog + .filter((g) => isProducing(g, setup, owned) && fullInMs(g, stock[g.id] ?? 0, setup) <= 0) + .map((g) => g.id) +} + +/** The producing goods that fill up next (not full yet), if any. */ +export function nextToFill( + catalog: readonly Goods[], + stock: GoodsStock, + setup: WarehouseSetup, + owned: OwnedProperties, +): string | undefined { + let next: { id: string; ms: number } | undefined + for (const goods of catalog) { + if (!isProducing(goods, setup, owned)) continue + const ms = fullInMs(goods, stock[goods.id] ?? 0, setup) + if (ms > 0 && (!next || ms < next.ms)) next = { id: goods.id, ms } + } + return next?.id +} + +/** + * Counts a stretch of play time: each producing goods type gains crates until full. Stock above + * the capacity (e.g. after selling a floor) stays as it is. Returns `stock` itself if nothing + * changed, so an idle tick does not trigger a save. + */ +export function produceGoods( + stock: GoodsStock, + catalog: readonly Goods[], + properties: readonly Property[], + owned: OwnedProperties, + playMs: number, +): GoodsStock { + const nightclub = properties.find((p) => p.id === NIGHTCLUB_ID) + if (!nightclub || !isOwned(owned, nightclub.id) || playMs <= 0) return stock + const setup = warehouseSetup(owned, nightclub) + let next = stock + for (const goods of catalog) { + if (!isProducing(goods, setup, owned)) continue + const before = stock[goods.id] ?? 0 + const cap = capacity(goods, setup.floors) + if (before >= cap) continue + const after = Math.min(cap, before + playMs / msPerCrate(goods, setup.equipment)) + next = { ...next, [goods.id]: after } + } + return next +} + +/** + * Checks saved stock. Entries are kept for any id, like owned properties (decision 011). + * Returns an error message if the shape is wrong. + */ +export function parseGoodsStock(data: unknown): GoodsStock | string { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return 'The nightclub section is missing.' + } + const result: GoodsStock = {} + for (const [id, crates] of Object.entries(data)) { + if (typeof crates !== 'number' || !Number.isFinite(crates) || crates < 0) { + return `The saved nightclub stock of "${id}" is not readable.` + } + result[id] = crates + } + return result +} + +/** Sale value made per hour of play time while not full. */ +export function valuePerHour(goods: Goods, equipment: boolean): number { + return (goods.crateValue * 60 * MINUTE_MS) / msPerCrate(goods, equipment) +} + +/** One technician moved to other goods. `from` is 'none' if they were not assigned. */ +export interface TechnicianMove { + technician: number + from: string + to: string +} + +export interface TechnicianSuggestion { + /** The technician choices after the moves, one per technician (`technician-1` first). */ + picks: string[] + moves: TechnicianMove[] + /** Value per hour of play time it adds. */ + gainPerHour: number +} + +/** + * The assignment that makes the most money per hour from what the user owns: the unlocked goods + * that are not full, best value per hour first, one per available technician (Yohan always, + * the others once hired). Technicians already on one of those goods stay. Undefined if the + * current assignment earns as much. + */ +export function suggestTechnicians( + catalog: readonly Goods[], + stock: GoodsStock, + owned: OwnedProperties, + nightclub: Property, +): TechnicianSuggestion | undefined { + const setup = warehouseSetup(owned, nightclub) + const picks = technicianPicks(owned, nightclub) + const available = picks.flatMap((pick, i) => (i === 0 || pick !== 'none' ? [i] : [])) + const earning = (g: Goods) => isUnlocked(g, owned) && fullInMs(g, stock[g.id] ?? 0, setup) > 0 + const rate = (g: Goods) => valuePerHour(g, setup.equipment) + const best = catalog + .filter(earning) + .sort((a, b) => rate(b) - rate(a)) + .slice(0, available.length) + const total = (ids: Iterable) => + [...ids].reduce((sum, id) => { + const g = catalog.find((c) => c.id === id) + return g && earning(g) ? sum + rate(g) : sum + }, 0) + const now = total(new Set(picks)) + const gainPerHour = total(best.map((g) => g.id)) - now + if (gainPerHour <= 0) return undefined + + const next = [...picks] + const kept = new Set() + const free: number[] = [] + for (const i of available) { + const pick = picks[i]! + if (best.some((g) => g.id === pick) && !kept.has(pick)) kept.add(pick) + else free.push(i) + } + const missing = best.filter((g) => !kept.has(g.id)) + const moves: TechnicianMove[] = [] + missing.forEach((g, n) => { + const i = free[n]! + moves.push({ technician: i + 1, from: picks[i]!, to: g.id }) + next[i] = g.id + }) + return { picks: next, moves, gainPerHour } +} diff --git a/src/lib/production.svelte.ts b/src/lib/production.svelte.ts index 533bfe0..c8f80f2 100644 --- a/src/lib/production.svelte.ts +++ b/src/lib/production.svelte.ts @@ -1,9 +1,11 @@ -// Runs business production on play time (issue #6). Logic lives in production.ts; this file +// Runs business and nightclub production on play time (issues #6, #7). 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 { onPlayTime } from './clock.svelte' +import { produceGoods } from './nightclub' import { produceAll } from './production' import { store } from './store.svelte' @@ -17,4 +19,11 @@ onPlayTime((ms) => { Date.now(), SUPPLY_PURCHASE.fillsTo, ) + store.state.nightclub = produceGoods( + store.state.nightclub, + GOODS, + PROPERTIES, + store.state.properties, + ms, + ) }) diff --git a/src/lib/resync.test.ts b/src/lib/resync.test.ts index a5f20dd..a2ad693 100644 --- a/src/lib/resync.test.ts +++ b/src/lib/resync.test.ts @@ -18,6 +18,8 @@ describe('formatValue', () => { 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(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', () => { @@ -48,8 +50,13 @@ describe('parseValue', () => { expect(parseValue('0', 'duration')).toBe(0) }) + it('reads whole crates', () => { + expect(parseValue(' 12 ', 'crates')).toBe(12) + expect(parseValue('1.5', 'crates')).toBeNull() + }) + it('rejects empty, negative or malformed input', () => { - for (const unit of ['percent', 'money', 'duration'] as const) { + for (const unit of ['percent', 'money', 'duration', 'crates'] as const) { expect(parseValue('', unit)).toBeNull() expect(parseValue('-5', unit)).toBeNull() expect(parseValue('abc', unit)).toBeNull() diff --git a/src/lib/resync.ts b/src/lib/resync.ts index a367fb4..3e60b91 100644 --- a/src/lib/resync.ts +++ b/src/lib/resync.ts @@ -2,8 +2,11 @@ // 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. -/** percent: stock and supplies. money: safes, in dollars. duration: time left, in milliseconds. */ -export type ValueUnit = 'percent' | 'money' | 'duration' +/** + * percent: stock and supplies. money: safes, in dollars. duration: time left, in milliseconds. + * crates: nightclub goods, whole crates as the game shows them. + */ +export type ValueUnit = 'percent' | 'money' | 'duration' | 'crates' export interface ValueBounds { min?: number @@ -38,6 +41,9 @@ export function formatValue(value: number, unit: ValueUnit): string { const minutes = Math.ceil(Math.max(0, value) / MINUTE_MS) return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}` } + case 'crates': + // Whole crates made so far; the margin keeps 3.9999... at 4 after float math. + return String(Math.floor(value + 1e-6)) } } @@ -48,6 +54,7 @@ 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 + * - crates: a whole number, e.g. "12" */ export function parseValue(text: string, unit: ValueUnit): number | null { const trimmed = text.trim().toLowerCase() @@ -72,6 +79,8 @@ export function parseValue(text: string, unit: ValueUnit): number | null { const minutes = nonNegative(trimmed) return minutes === null ? null : Math.round(minutes * MINUTE_MS) } + case 'crates': + return /^\d+$/.test(trimmed) ? Number(trimmed) : null } } diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index 7d43ea0..69fe5df 100644 --- a/src/lib/state.test.ts +++ b/src/lib/state.test.ts @@ -25,11 +25,12 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 5, + version: 6, clock: { trackOnOpen: false }, properties: {}, businesses: {}, businessOrder: [], + nightclub: {}, }, fromVersion: 1, }) @@ -40,11 +41,12 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 5, + version: 6, clock: { trackOnOpen: true }, properties: {}, businesses: {}, businessOrder: [], + nightclub: {}, }, fromVersion: 2, }) @@ -58,11 +60,12 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 5, + version: 6, clock: { trackOnOpen: false }, properties, businesses: {}, businessOrder: [], + nightclub: {}, }, fromVersion: 3, }) @@ -77,19 +80,41 @@ describe('parseState', () => { expect(result).toEqual({ ok: true, state: { - version: 5, + version: 6, clock: { trackOnOpen: true }, properties, businesses, businessOrder: [], + nightclub: {}, }, fromVersion: 4, }) }) + it('migrates a version 5 sample, with an empty nightclub warehouse', () => { + const properties = { nightclub: { owned: true, upgrades: { 'technician-1': 'cash' } } } + const sample = { + version: 5, + clock: { trackOnOpen: false }, + properties, + businesses: {}, + businessOrder: ['bunker'], + } + expect(parseState(JSON.stringify(sample))).toEqual({ + ok: true, + state: { ...sample, version: 6, nightclub: {} }, + fromVersion: 5, + }) + }) + + it('keeps saved nightclub stock', () => { + const text = JSON.stringify({ ...defaultState(), nightclub: { cash: 12.5 } }) + expect(parseState(text)).toMatchObject({ ok: true, state: { nightclub: { cash: 12.5 } } }) + }) + it('keeps the saved clock setting', () => { const text = - '{"version":5,"clock":{"trackOnOpen":true,"playMs":5},"properties":{},"businesses":{},"businessOrder":[]}' + '{"version":6,"clock":{"trackOnOpen":true,"playMs":5},"properties":{},"businesses":{},"businessOrder":[],"nightclub":{}}' expect(parseState(text)).toMatchObject({ ok: true, state: { clock: { trackOnOpen: true } } }) }) @@ -114,27 +139,27 @@ describe('parseState', () => { it.each([ [ 'a missing clock', - '{"version":5,"properties":{},"businesses":{},"businessOrder":[]}', + '{"version":5,"properties":{},"businesses":{},"businessOrder":[],"nightclub":{}}', 'clock section is missing', ], [ 'a bad tracking setting', - '{"version":5,"clock":{"trackOnOpen":"yes"},"properties":{},"businesses":{},"businessOrder":[]}', + '{"version":5,"clock":{"trackOnOpen":"yes"},"properties":{},"businesses":{},"businessOrder":[],"nightclub":{}}', 'tracking setting', ], [ 'missing properties', - '{"version":5,"clock":{"trackOnOpen":false},"businesses":{},"businessOrder":[]}', + '{"version":5,"clock":{"trackOnOpen":false},"businesses":{},"businessOrder":[],"nightclub":{}}', 'properties section', ], [ 'missing businesses', - '{"version":5,"clock":{"trackOnOpen":false},"properties":{},"businessOrder":[]}', + '{"version":5,"clock":{"trackOnOpen":false},"properties":{},"businessOrder":[],"nightclub":{}}', 'businesses section', ], [ 'stock over 100%', - '{"version":5,"clock":{"trackOnOpen":false},"properties":{},"businesses":{"bunker":{"stock":120}},"businessOrder":[]}', + '{"version":5,"clock":{"trackOnOpen":false},"properties":{},"businesses":{"bunker":{"stock":120}},"businessOrder":[],"nightclub":{}}', 'business "bunker"', ], [ diff --git a/src/lib/state.ts b/src/lib/state.ts index 3c8f8d8..6d9c03f 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -1,11 +1,12 @@ // The persisted root state, its versions and migrations (decision 006). import { defaultClockSettings, type ClockSettings } from './clock' +import { parseGoodsStock, type GoodsStock } from './nightclub' import { parseOrder } from './order' import { parseBusinessBars, type BusinessBarsById } from './production' import { parseOwnedProperties, type OwnedProperties } from './properties' /** Bump when the stored shape changes, and add a migration from the previous version. */ -export const CURRENT_VERSION = 5 +export const CURRENT_VERSION = 6 /** All user state. Trackers add their own sections here as they are built. */ export interface AppState { @@ -18,6 +19,8 @@ export interface AppState { businesses: BusinessBarsById /** Property ids in the order the user dragged the business cards into (issue #30). */ businessOrder: string[] + /** Nightclub warehouse crates by goods id, as of the last play time counted (issue #7). */ + nightclub: GoodsStock } export function defaultState(): AppState { @@ -27,6 +30,7 @@ export function defaultState(): AppState { properties: {}, businesses: {}, businessOrder: [], + nightclub: {}, } } @@ -45,6 +49,8 @@ export const MIGRATIONS: Readonly> = { 3: (data) => ({ ...data, businesses: {} }), // Version 5 adds the order of the business cards (issue #30); catalog order until moved. 4: (data) => ({ ...data, businessOrder: [] }), + // Version 6 adds the nightclub warehouse stock (issue #7); empty until set or produced. + 5: (data) => ({ ...data, nightclub: {} }), } export type ParseResult = @@ -121,7 +127,9 @@ function toState(data: StoredData): AppState | string { if (typeof businesses === 'string') return businesses const businessOrder = parseOrder(data.businessOrder) if (typeof businessOrder === 'string') return businessOrder - return { version: CURRENT_VERSION, clock, properties, businesses, businessOrder } + const nightclub = parseGoodsStock(data.nightclub) + if (typeof nightclub === 'string') return nightclub + return { version: CURRENT_VERSION, clock, properties, businesses, businessOrder, nightclub } } function toClock(data: unknown): ClockSettings | string {