From 68dfd427d9c755486b284413ca000e3054f1e004 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:14:45 +0000 Subject: [PATCH 1/5] Resync tracked values by hand: EditableValue, units, presets, undo Click a value, type what the game shows (percent, $ or h:mm), confirm. Trackers pass one-click presets such as Sold or Full and store the value as of now. Undo takes back the last edit and keeps progress counted since. Decision 010. Closes #4 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/010-resync.md | 31 ++++ docs/decisions/README.md | 1 + src/components/ui/EditableValue.svelte | 222 +++++++++++++++++++++++++ src/components/ui/Kit.svelte | 45 +++++ src/lib/resync.test.ts | 77 +++++++++ src/lib/resync.ts | 90 ++++++++++ 7 files changed, 467 insertions(+) create mode 100644 docs/decisions/010-resync.md create mode 100644 src/components/ui/EditableValue.svelte create mode 100644 src/lib/resync.test.ts create mode 100644 src/lib/resync.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2fb6e85..1ae675a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Saved user state, migrations, export/import | `src/lib/state.ts`, `src/lib/store.svelte.ts` | | UI components (thin) | `src/components/` | | Base UI kit (panel, meter, button...), preview | `src/components/ui/`, `#/kit` in dev (dec. 009) | +| Resync a tracked value (edit, presets, undo) | `ui/EditableValue.svelte`, `lib/resync.ts` (010) | | Game data with sources | `src/data/` (decision 004) | | Theme tokens | `src/app.css` (decision 009) | | CI, templates | `.github/` | diff --git a/docs/decisions/010-resync.md b/docs/decisions/010-resync.md new file mode 100644 index 0000000..2d4c4ce --- /dev/null +++ b/docs/decisions/010-resync.md @@ -0,0 +1,31 @@ +# 010: Resync: correcting tracked values by hand + +2026-10-14, issue #4. Builds on decision 003 ("Resync"). + +## Decision + +- **One component for every tracked value:** `src/components/ui/EditableValue.svelte`. It shows + the current estimate; clicking it opens a text field prefilled with that value. Enter or "Set" + confirms, Escape or "Cancel" leaves it unchanged. Input the field cannot read is not guessed + at: it stays open and says what it expects. +- **Units** (`src/lib/resync.ts`): `percent` (stock, supplies), `money` (safes; "$120,000", + "120k" and "1.2m" all work) and `duration` (time left as h:mm or plain minutes, stored in + milliseconds; shown rounded up to the minute, so "0:00" means done). Typed values are clamped + to the tracker's `min`/`max`. "Bars" is not a unit yet: how many bars a business screen shows + is a game fact without a source (decision 004). +- **The tracker owns "as of now".** The component calls `onresync(value)`; the tracker stores + it with its own clock and keeps counting from there (a play-time tracker sets its saved + progress, a real-time cooldown stores `now + timeLeft`). The component never reads a clock. +- **Presets** are one-click values passed in by the tracker ("Sold" sets stock to 0, "Full" to + the maximum). What "Raided" and similar events do is game data, so each tracker defines its + own presets with sources; the component only applies them. +- **Undo takes back the last edit or preset** of that value. It subtracts what the edit changed + rather than restoring the old number, so progress counted since is kept: a cooldown mistyped + as 1:30 instead of 2:00 and undone ten minutes later shows 1:50. The undo lives in the + component for the session and is not saved. + +## Why + +Estimates drift (forgotten pause, disconnects, raids), so correcting a value must be one click +and a number, everywhere, in the unit the game shows. Keeping the clock out of the component +lets each tracker use its own clock (decision 003) and keeps the logic testable. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 16c651b..50fccb6 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -20,3 +20,4 @@ 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, units, presets from trackers, undo keeps progress since diff --git a/src/components/ui/EditableValue.svelte b/src/components/ui/EditableValue.svelte new file mode 100644 index 0000000..b6629c8 --- /dev/null +++ b/src/components/ui/EditableValue.svelte @@ -0,0 +1,222 @@ + + +
+
+ {label} + {#if editing} +
+ (invalid = false)} + onkeydown={(event) => { + if (event.key === 'Escape') stopEditing() + }} + /> + + +
+ {:else} + + {/if} +
+ + {#if editing} +

+ {invalid ? `Not understood. ${HINTS[unit]}.` : HINTS[unit]} +

+ {/if} + + {#if presets.length > 0 || last} +
+ {#each presets as preset (preset.label)} + + {/each} + {#if last} + + {/if} +
+ {/if} +
+ + diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index 875db6b..e795656 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -3,11 +3,21 @@ // Sample numbers only: no game values here (decision 004). import Button from './Button.svelte' import Countdown from './Countdown.svelte' + import EditableValue from './EditableValue.svelte' import Meter from './Meter.svelte' import NumberField from './NumberField.svelte' import Panel from './Panel.svelte' let amount = $state(40) + let safe = $state(70_000) + + // A real-time cooldown, as a tracker would keep it: the moment it is ready. + let now = $state(Date.now()) + let readyAt = $state(Date.now() + 90 * 60_000) + $effect(() => { + const timer = setInterval(() => (now = Date.now()), 1000) + return () => clearInterval(timer) + })
@@ -36,6 +46,41 @@ onchange={(value) => (amount = value)} /> + + + + (amount = value)} + /> + (safe = value)} + /> + { + now = Date.now() + readyAt = now + value + }} + /> +
diff --git a/src/components/ui/EditableValue.svelte b/src/components/ui/EditableValue.svelte index b6629c8..978888c 100644 --- a/src/components/ui/EditableValue.svelte +++ b/src/components/ui/EditableValue.svelte @@ -1,5 +1,5 @@
-
- {label} - {text} -
+ {#if !bare} +
+ {label} + {text} +
+ {/if}
= { + sold: { + label: 'Sold', + empties: ['stock'], + source: 'Maintainer, from play (issue #4): selling empties the stock, supplies stay.', + checked: '2026-10-14', + }, + raided: { + label: 'Raided', + empties: ['stock', 'supplies'], + source: 'Maintainer, from play (issue #4): a successful police raid zeroes stock and supplies.', + checked: '2026-10-14', + }, +} diff --git a/src/lib/resync.test.ts b/src/lib/resync.test.ts index b226149..a351cfc 100644 --- a/src/lib/resync.test.ts +++ b/src/lib/resync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { clampValue, formatValue, parseValue, undoValue } from './resync' +import { applyEvent, clampValue, formatValue, parseValue, undoBars, undoValue } from './resync' const MIN = 60_000 @@ -75,3 +75,30 @@ describe('undoValue', () => { expect(undoValue({ before: 0, after: 50 }, 20, { min: 0 })).toBe(0) }) }) + +describe('applyEvent', () => { + it('empties the listed bars and keeps the rest', () => { + expect(applyEvent({ stock: 80, supplies: 40 }, ['stock'])).toEqual({ stock: 0, supplies: 40 }) + expect(applyEvent({ stock: 80, supplies: 40 }, ['stock', 'supplies'])).toEqual({ + stock: 0, + supplies: 0, + }) + }) + + it('does not add a supplies bar to a business without one', () => { + expect(applyEvent({ stock: 80 }, ['stock', 'supplies'])).toEqual({ stock: 0 }) + }) +}) + +describe('undoBars', () => { + it('takes back a raid and keeps what changed since', () => { + const edit = { before: { stock: 80, supplies: 40 }, after: { stock: 0, supplies: 0 } } + expect(undoBars(edit, { stock: 0, supplies: 0 })).toEqual({ stock: 80, supplies: 40 }) + expect(undoBars(edit, { stock: 5, supplies: 20 })).toEqual({ stock: 85, supplies: 60 }) + }) + + it('clamps each bar to 0..100 and leaves untouched bars alone', () => { + const edit = { before: { stock: 90, supplies: 50 }, after: { stock: 10, supplies: 50 } } + expect(undoBars(edit, { stock: 30, supplies: 45 })).toEqual({ stock: 100, supplies: 45 }) + }) +}) diff --git a/src/lib/resync.ts b/src/lib/resync.ts index 798b04a..fc0b64f 100644 --- a/src/lib/resync.ts +++ b/src/lib/resync.ts @@ -88,3 +88,31 @@ function nonNegative(text: string): number | null { export function undoValue(edit: Resync, current: number, bounds: ValueBounds = {}): number { return clampValue(current - (edit.after - edit.before), bounds) } + +/** A business's bars in percent. Some businesses have only a stock bar. */ +export interface Bars { + stock: number + supplies?: number +} + +const PERCENT: ValueBounds = { min: 0, max: 100 } + +/** The bars after an event such as a sale or a raid: the bars it empties go to zero. */ +export function applyEvent(bars: Bars, empties: readonly (keyof Bars)[]): Bars { + const next = { ...bars } + for (const bar of empties) if (next[bar] !== undefined) next[bar] = 0 + return next +} + +/** `undoValue` for each bar, for an edit or event that changed several at once. */ +export function undoBars(edit: { before: Bars; after: Bars }, current: Bars): Bars { + const next = { ...current } + for (const bar of ['stock', 'supplies'] as const) { + const before = edit.before[bar] + const after = edit.after[bar] + const now = current[bar] + if (before === undefined || after === undefined || now === undefined) continue + next[bar] = undoValue({ before, after }, now, PERCENT) + } + return next +} From c5c68f46addd7f94d392c4b0b43b282f87f3b159 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:27:44 +0000 Subject: [PATCH 3/5] Supplies bought: delivery arrives after 10 minutes and fills supplies Replaces Full on the supplies bar. The bars keep the delivery time; settleBars fills supplies to 100% once it is due. Real time until the game's clock for it is confirmed. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/010-resync.md | 17 +++++++---- docs/decisions/README.md | 2 +- src/components/ui/BusinessBars.svelte | 38 +++++++++++++++++++++---- src/components/ui/Kit.svelte | 4 +-- src/data/business-events.ts | 14 +++++++++ src/lib/resync.test.ts | 41 ++++++++++++++++++++++++++- src/lib/resync.ts | 22 ++++++++++++++ 7 files changed, 124 insertions(+), 14 deletions(-) diff --git a/docs/decisions/010-resync.md b/docs/decisions/010-resync.md index 888049d..dd4c71e 100644 --- a/docs/decisions/010-resync.md +++ b/docs/decisions/010-resync.md @@ -19,11 +19,18 @@ - **Presets** are one-click values passed in by the tracker ("Full" sets a bar to 100%, "Collected" empties a safe). - **Business bars** (`src/components/ui/BusinessBars.svelte`): stock and, if the business has - one, supplies, each an `EditableValue` with its meter and "Full", plus the business events - "Sold" (empties stock) and "Raided" (a successful police raid empties stock and supplies). - What each event empties is game data in `src/data/business-events.ts` (decision 004); Raided - is hidden for a business without supplies. Trackers pass the bars in percent and store what - comes back. + one, supplies, each an `EditableValue` with its meter, plus the business events "Sold" + (empties stock) and "Raided" (a successful police raid empties stock and supplies). Stock has + "Full". Raided is hidden for a business without supplies. Trackers pass the bars in percent + and store what comes back. +- **Supplies bought** starts a delivery instead of filling at once: the bars keep + `suppliesDueAt` (wall-clock time), the panel counts down, and once due `settleBars` fills + supplies to 100%. Trackers store bars as set and settle them whenever they read them, so a + delivery that arrives while nobody looks still counts. The wait is 10 minutes for every + business and runs on real time for now; which clock the game uses is not confirmed. A raid + leaves a pending delivery as it is (also not confirmed). +- What each event does, and the delivery, are game data in `src/data/business-events.ts` + (decision 004). - **Undo takes back the last edit or preset** of that value; in `BusinessBars` one Undo covers the last change to either bar, events included. It subtracts what the edit changed rather than restoring the old number, so progress counted since is kept: a cooldown mistyped diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 48af282..8a0c1c8 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -20,4 +20,4 @@ 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, undo keeps progress since +- [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since diff --git a/src/components/ui/BusinessBars.svelte b/src/components/ui/BusinessBars.svelte index 9fe42b2..bc536a6 100644 --- a/src/components/ui/BusinessBars.svelte +++ b/src/components/ui/BusinessBars.svelte @@ -1,18 +1,32 @@
@@ -58,12 +81,17 @@ unit="percent" min={0} max={100} - presets={FULL} undoable={false} onresync={(supplies) => set({ ...bars, supplies }, `supplies was ${formatValue(bars.supplies ?? 0, 'percent')}`)} > + {#if dueAt !== undefined} + + {/if} +
+ +
{/if}
diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index f8282de..4654a17 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -52,11 +52,11 @@ - (bars = value)} /> + (bars = value)} /> - (stockOnly = value)} /> + (stockOnly = value)} /> diff --git a/src/data/business-events.ts b/src/data/business-events.ts index 680e4ce..7120366 100644 --- a/src/data/business-events.ts +++ b/src/data/business-events.ts @@ -24,3 +24,17 @@ export const BUSINESS_EVENTS: Record<'sold' | 'raided', BusinessEvent> = { checked: '2026-10-14', }, } + +/** Buying supplies: they arrive after a delay and fill the supplies bar. */ +export const SUPPLY_PURCHASE = { + label: 'Supplies bought', + deliveryMs: 10 * 60_000, + /** Supplies after delivery, in percent. */ + fillsTo: 100, + /** Not confirmed yet which clock the wait runs on; real time until it is (issue #4). */ + clock: 'real' as const, + source: + 'Maintainer, from play (issue #4): about 10 minutes for every business, then supplies are full. ' + + 'Community wiki (gta.wiki, Business Supplies) also says about ten minutes for MC businesses.', + checked: '2026-10-14', +} diff --git a/src/lib/resync.test.ts b/src/lib/resync.test.ts index a351cfc..dde3789 100644 --- a/src/lib/resync.test.ts +++ b/src/lib/resync.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest' -import { applyEvent, clampValue, formatValue, parseValue, undoBars, undoValue } from './resync' +import { + applyEvent, + buySupplies, + clampValue, + formatValue, + parseValue, + settleBars, + undoBars, + undoValue, +} from './resync' const MIN = 60_000 @@ -102,3 +111,33 @@ describe('undoBars', () => { expect(undoBars(edit, { stock: 30, supplies: 45 })).toEqual({ stock: 100, supplies: 45 }) }) }) + +describe('buying supplies', () => { + const T = 1_000_000 + const DELIVERY = 10 * MIN + + it('schedules a delivery and changes nothing until it arrives', () => { + const bought = buySupplies({ stock: 20, supplies: 5 }, T, DELIVERY) + expect(bought).toEqual({ stock: 20, supplies: 5, suppliesDueAt: T + DELIVERY }) + expect(settleBars(bought, T + DELIVERY - 1, 100)).toBe(bought) + }) + + it('fills supplies once the delivery is due', () => { + const bought = buySupplies({ stock: 20, supplies: 5 }, T, DELIVERY) + expect(settleBars(bought, T + DELIVERY, 100)).toEqual({ stock: 20, supplies: 100 }) + }) + + it('does nothing for a business without supplies', () => { + expect(buySupplies({ stock: 20 }, T, DELIVERY)).toEqual({ stock: 20 }) + }) + + it('undo cancels a pending delivery and restores one an edit replaced', () => { + const before = { stock: 20, supplies: 5 } + const after = buySupplies(before, T, DELIVERY) + expect(undoBars({ before, after }, after)).toEqual(before) + + const pending = { stock: 20, supplies: 5, suppliesDueAt: T } + const raided = { stock: 0, supplies: 0, suppliesDueAt: T } + expect(undoBars({ before: pending, after: raided }, raided)).toEqual(pending) + }) +}) diff --git a/src/lib/resync.ts b/src/lib/resync.ts index fc0b64f..81863e3 100644 --- a/src/lib/resync.ts +++ b/src/lib/resync.ts @@ -93,6 +93,8 @@ export function undoValue(edit: Resync, current: number, bounds: ValueBounds = { export interface Bars { stock: number supplies?: number + /** Wall-clock time bought supplies arrive, while a delivery is on its way. */ + suppliesDueAt?: number } const PERCENT: ValueBounds = { min: 0, max: 100 } @@ -104,9 +106,29 @@ export function applyEvent(bars: Bars, empties: readonly (keyof Bars)[]): Bars { return next } +/** The bars after buying supplies at `now`: a delivery arrives `deliveryMs` later. */ +export function buySupplies(bars: Bars, now: number, deliveryMs: number): Bars { + if (bars.supplies === undefined) return bars + return { ...bars, suppliesDueAt: now + deliveryMs } +} + +/** + * The bars as of `now`: a delivery that has arrived fills supplies to `fillsTo` and is done. + * Trackers store bars as they were set and settle them whenever they read them. + */ +export function settleBars(bars: Bars, now: number, fillsTo: number): Bars { + if (bars.suppliesDueAt === undefined || now < bars.suppliesDueAt) return bars + const { suppliesDueAt: _, ...rest } = bars + return rest.supplies === undefined ? rest : { ...rest, supplies: fillsTo } +} + /** `undoValue` for each bar, for an edit or event that changed several at once. */ export function undoBars(edit: { before: Bars; after: Bars }, current: Bars): Bars { const next = { ...current } + if (edit.before.suppliesDueAt !== edit.after.suppliesDueAt) { + if (edit.before.suppliesDueAt === undefined) delete next.suppliesDueAt + else next.suppliesDueAt = edit.before.suppliesDueAt + } for (const bar of ['stock', 'supplies'] as const) { const before = edit.before[bar] const after = edit.after[bar] From cecf1ad1358297b5685e07a782a2782f838b19a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:28:50 +0000 Subject: [PATCH 4/5] CLAUDE.md: gta.wiki is a trusted source for game facts Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0d8fecb..38f2c0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ npm run format # fix formatting so. Check with the maintainer when it tries to redirect the task. - **Never invent game facts** (rates, capacities, cooldowns, payouts). Use only sourced values in `src/data/` (decision 004). Missing → leave it out, comment the open question on the issue, ask. + gta.wiki is a trusted source for in-game data and facts (cite the page as the source). - Ambiguous task → ask instead of guessing. Simple, googleable facts → look them up. - **Determinism:** logic takes `now` as input or uses an injected clock; no `Date.now()` in logic, no real timers in tests (decision 003). From c7df72a4dae43b569d3381f1ebe372737abfa694 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:29:41 +0000 Subject: [PATCH 5/5] Raided also cancels supplies on their way Co-Authored-By: Claude Opus 5.5 --- docs/decisions/010-resync.md | 2 +- src/components/ui/BusinessBars.svelte | 4 +--- src/data/business-events.ts | 8 +++++++- src/lib/resync.test.ts | 17 +++++++++++++---- src/lib/resync.ts | 13 ++++++++++--- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/docs/decisions/010-resync.md b/docs/decisions/010-resync.md index dd4c71e..168b7de 100644 --- a/docs/decisions/010-resync.md +++ b/docs/decisions/010-resync.md @@ -28,7 +28,7 @@ supplies to 100%. Trackers store bars as set and settle them whenever they read them, so a delivery that arrives while nobody looks still counts. The wait is 10 minutes for every business and runs on real time for now; which clock the game uses is not confirmed. A raid - leaves a pending delivery as it is (also not confirmed). + also cancels a delivery on its way. - What each event does, and the delivery, are game data in `src/data/business-events.ts` (decision 004). - **Undo takes back the last edit or preset** of that value; in `BusinessBars` one Undo covers diff --git a/src/components/ui/BusinessBars.svelte b/src/components/ui/BusinessBars.svelte index bc536a6..f540786 100644 --- a/src/components/ui/BusinessBars.svelte +++ b/src/components/ui/BusinessBars.svelte @@ -96,9 +96,7 @@ {/if}
{#each events as event (event.label)} - + {/each} {#if last} diff --git a/src/data/business-events.ts b/src/data/business-events.ts index 7120366..3026599 100644 --- a/src/data/business-events.ts +++ b/src/data/business-events.ts @@ -6,6 +6,8 @@ export interface BusinessEvent { label: string /** Bars the event sets to zero. */ empties: Bar[] + /** Also cancels bought supplies that are still on their way. */ + cancelsDelivery: boolean source: string checked: string } @@ -14,13 +16,17 @@ export const BUSINESS_EVENTS: Record<'sold' | 'raided', BusinessEvent> = { sold: { label: 'Sold', empties: ['stock'], + cancelsDelivery: false, source: 'Maintainer, from play (issue #4): selling empties the stock, supplies stay.', checked: '2026-10-14', }, raided: { label: 'Raided', empties: ['stock', 'supplies'], - source: 'Maintainer, from play (issue #4): a successful police raid zeroes stock and supplies.', + cancelsDelivery: true, + source: + 'Maintainer, from play (issue #4): a successful police raid zeroes stock and supplies ' + + 'and cancels supplies still on their way.', checked: '2026-10-14', }, } diff --git a/src/lib/resync.test.ts b/src/lib/resync.test.ts index dde3789..f7759f3 100644 --- a/src/lib/resync.test.ts +++ b/src/lib/resync.test.ts @@ -86,16 +86,25 @@ describe('undoValue', () => { }) describe('applyEvent', () => { + const sold = { empties: ['stock'] as const, cancelsDelivery: false } + const raided = { empties: ['stock', 'supplies'] as const, cancelsDelivery: true } + it('empties the listed bars and keeps the rest', () => { - expect(applyEvent({ stock: 80, supplies: 40 }, ['stock'])).toEqual({ stock: 0, supplies: 40 }) - expect(applyEvent({ stock: 80, supplies: 40 }, ['stock', 'supplies'])).toEqual({ + expect(applyEvent({ stock: 80, supplies: 40 }, sold)).toEqual({ stock: 0, supplies: 40 }) + expect(applyEvent({ stock: 80, supplies: 40 }, raided)).toEqual({ stock: 0, supplies: 0, }) }) it('does not add a supplies bar to a business without one', () => { - expect(applyEvent({ stock: 80 }, ['stock', 'supplies'])).toEqual({ stock: 0 }) + expect(applyEvent({ stock: 80 }, raided)).toEqual({ stock: 0 }) + }) + + it('drops a pending delivery only if the event cancels it', () => { + const pending = { stock: 80, supplies: 40, suppliesDueAt: 5 } + expect(applyEvent(pending, sold)).toEqual({ stock: 0, supplies: 40, suppliesDueAt: 5 }) + expect(applyEvent(pending, raided)).toEqual({ stock: 0, supplies: 0 }) }) }) @@ -137,7 +146,7 @@ describe('buying supplies', () => { expect(undoBars({ before, after }, after)).toEqual(before) const pending = { stock: 20, supplies: 5, suppliesDueAt: T } - const raided = { stock: 0, supplies: 0, suppliesDueAt: T } + const raided = { stock: 0, supplies: 0 } expect(undoBars({ before: pending, after: raided }, raided)).toEqual(pending) }) }) diff --git a/src/lib/resync.ts b/src/lib/resync.ts index 81863e3..69baa64 100644 --- a/src/lib/resync.ts +++ b/src/lib/resync.ts @@ -99,10 +99,17 @@ export interface Bars { const PERCENT: ValueBounds = { min: 0, max: 100 } -/** The bars after an event such as a sale or a raid: the bars it empties go to zero. */ -export function applyEvent(bars: Bars, empties: readonly (keyof Bars)[]): Bars { +/** + * The bars after an event such as a sale or a raid: the bars it empties go to zero, and a + * delivery on its way is dropped if the event cancels it. + */ +export function applyEvent( + bars: Bars, + event: { empties: readonly ('stock' | 'supplies')[]; cancelsDelivery: boolean }, +): Bars { const next = { ...bars } - for (const bar of empties) if (next[bar] !== undefined) next[bar] = 0 + for (const bar of event.empties) if (next[bar] !== undefined) next[bar] = 0 + if (event.cancelsDelivery) delete next.suppliesDueAt return next }