diff --git a/CLAUDE.md b/CLAUDE.md index 952498d..42a832a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Shut-down businesses (owned, not running) | `isRunning` in `lib/properties.ts` (020) | | Bunker staff on manufacturing/both/research | `staffSplit` in `data/production.ts` (025) | | Real-time timers: Lucky Wheel, Mansion boost | `data/timers.ts`, `lib/timers.ts` (022) | +| Weekly events and the Weekly tab | `data/weekly.ts`, `lib/weekly.ts` (decision 027) | | Overview tab, next events, notifications | `lib/events.ts`, `lib/overview.ts` (dec. 023) | | Theme tokens | `src/app.css` (decision 009) | | Link previews (Open Graph), preview image | `index.html`, `scripts/og-image.html` | diff --git a/README.md b/README.md index 59e86a5..6112fec 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ emptying. collected, spun), and get optional alerts as pop-ups or browser notifications. - **Resync anytime.** When an estimate drifts from what the game shows, click the value and type the real one. -- **Weekly events** (bonuses, discounts) come from a data file in this repo that's updated weekly. +- **Weekly events** (bonuses, discounts, rotations) come from a data file in this repo that's updated + weekly, copied from gta.wiki. They're on the Weekly tab. Targets the current-gen edition (PC Enhanced, PS5, Xbox Series X|S). diff --git a/docs/decisions/027-weekly-contents.md b/docs/decisions/027-weekly-contents.md new file mode 100644 index 0000000..51a66a4 --- /dev/null +++ b/docs/decisions/027-weekly-contents.md @@ -0,0 +1,69 @@ +# 027: Weekly events: one typed file per week, its contents, week and source + +2026-10-15, issue #16 (format) and #17 (routine). Builds on decision 005. + +## Decision + +- **One typed TypeScript file, not JSON.** The week is `WEEK` in `src/data/weekly.ts`, typed by + `Week` in `src/lib/weekly.ts`, and bundled with the site. The type checker is the schema, and + a unit test runs `weekProblems` on it (dates in order, factors above 1, discounts 1 to 100%, + known property ids, a gta.wiki source), so `npm run verify` and CI reject a bad week before it + merges. No fetch, loading state or runtime validator. This replaces the `public/weekly.json` + of issue #16; decision 005 (a committed file, updated by pull request) still holds. Most + entries are plain strings, copied as the wiki words them; only what the UI sorts or groups + by has fields (a multiplier's factor and property, a discount's kind and percent). +- **Contents**, per week: + - **Money multipliers**: activity, factor (2x, 3x, ...), what it pays (GTA$, RP or both). + - **Weekly Challenge**: the objective and its reward. + - **Rotations**: Salvage Yard robbery vehicles, FIB Priority File, Kortz Center paintings, + Lucky Wheel podium vehicle, Prize Ride (vehicle and condition), LS Car Meet Test Rides, + Luxury Autos and Premium Deluxe Motorsport showrooms, Gun Van weapon stock, Premium Race, + Time Trial, HSW Time Trial and HSW Premium Test Ride. + - **Discounts**: vehicles, properties and Gun Van weapons, each with its percentage (free = 100). + - **Gifts and one-off bonuses**: free items, and rewards for doing something this week + (e.g. "complete X, get GTA$500,000 within 72 hours"), with any deadline. + - **Still running**: seasonal activities carried over from earlier weeks without a bonus + this week (e.g. Ghosts Exposed, Phantom Car), names only. Shown below the week's events, + more out of the way, in a collapsed "Also running" list. + - **Week**: start and end as exact UTC instants, plus the source link. +- **Left out:** + - **GTA+**: no member-only multipliers, discounts or monthly benefits. Where an entry has a + separate GTA+ value (e.g. "3x, 6x for GTA+"), only the standard value is kept. + - **Daily, map-bound rotations**: the Gun Van's location, and the daily Junk Energy and RC + Time Trials. They change every day and only make sense on a map, which LSOS does not have. + The Gun Van's weekly stock and discounts stay (they are not tied to a place). +- **Shown on the Weekly tab only**: a header with the week's dates, a status pill (live with + time left, or out of date) and the source links; bonus payouts (factor badge, activity, what + it pays) next to the Weekly Challenge and gifts; the rotation and the discounts (by vehicles, + properties and Gun Van, then by percentage) as labelled blocks of chips; then a collapsed + "Also running" list. The panels keep a fixed order: unlike the business cards, there is + nothing to compare between them. +- **No Breaking News bar.** Issue #16 asked for a ticker on every tab; it was built and taken + out again before merging: more distracting than helpful. A multiplier names its `property` id when it pays through one (e.g. bounties + through the Bail Office), so owners see it first. +- **Out of date:** once `now >= end` the pill says "Out of date" and a warning says the events + are last week's; the old week stays readable below it, greyed and dimmed, instead of shown as + current or hidden. +- **The week** runs Thursday to the next Thursday. The file stores explicit `start` and `end` + instants and the site uses only those (staleness: `now >= end`), never a weekday rule: some + events ran 12 to 14 days (gta.wiki, Lucky Wheel). Instants are written as Thursday 2:00 AM US + Pacific time: 09:00 UTC while the US is on daylight saving time, 10:00 UTC otherwise. + Community guides give this time; no official source states it, so it is a convention for + writing the file, not a game fact used in logic. +- **Source: gta.wiki**, the weekly event page (`Category:Weekly events`, infobox + `type = weeklyevent`, e.g. "Halloween 2026 (Week 3)"). The routine reads its wikitext through + the MediaWiki API (`https://gta.wiki/api.php?action=parse&page=&prop=wikitext&format=json`). + The page's Rockstar Newswire link is kept as a second link in the file. +- **Routine time** (#17): Thursday late afternoon, about 16:00 UTC, so the wiki page is complete. + +## Why + +- The contents are the ones that matter to someone planning a session's money; GTA+ and map + widgets were declined for this project. +- gta.wiki is trusted for game facts (CLAUDE.md) and its weekly pages list every category above + under fixed headings, with a previous and next week link. The Rockstar Newswire needs + JavaScript to show its text, its API was not reachable from the cloud environment, and it + often posts one article for a whole month of weeks. +- Timing seen on 2026-10-15: the week's wiki page was created on 2026-10-14 at 23:53 UTC (before + the reset, likely from leaks) and its Gun Van, Salvage Yard and Prize Ride parts were filled in + between 11:52 and 11:57 UTC on Thursday. A run right after the reset would miss them. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1735618..a1c0684 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -37,3 +37,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [024](024-several-tabs.md) Several tabs: a tab takes the state another tab saves (`storage` event); only the tab that last started counting counts play time (`BroadcastChannel`) - [025](025-bunker-staff.md) Bunker staff on Manufacturing, Both or Research: a stock share (Both 0.5, Research 0) on the sourced rates, supplies used as fast on every option (est.), research progress not tracked - [026](026-movable-cards-everywhere.md) Movable cards on Businesses, Safes and Timers (`ui/CardGrid`): picked up by the title bar; swap by the dragged card's middle, 10% in; orders saved per tab +- [027](027-weekly-contents.md) Weekly events: typed `src/data/weekly.ts` (no JSON, no fetch), checked by a test; Weekly tab only (no news bar); out of date: warning over the dimmed old week; contents: multipliers, Weekly Challenge, rotations, discounts, gifts, a less prominent "Also running" list; no GTA+, no daily map-bound items; explicit UTC start/end (Thursday 2:00 AM US Pacific); source gta.wiki weekly page; routine Thursday ~16:00 UTC diff --git a/docs/weekly-update.md b/docs/weekly-update.md new file mode 100644 index 0000000..5f86412 --- /dev/null +++ b/docs/weekly-update.md @@ -0,0 +1,35 @@ +# Writing a week's events + +Each Thursday's GTA Online events go into `src/data/weekly.ts` (decision 027), replaced in full +by one pull request. By hand or by the weekly routine (#17), the steps are the same. + +1. **Wait for the page.** Find the week's page on gta.wiki: the newest in + [Category:Weekly events](https://gta.wiki/w/Category:Weekly_events), e.g. "Halloween 2026 + (Week 3)". It is filled in during Thursday; write the file in the late afternoon (UTC), when + the Gun Van, Salvage Yard and Prize Ride parts are in. Raw text: + `https://gta.wiki/api.php?action=parse&page=<title>&prop=wikitext&format=json`. +2. **Week.** `title` is the page title, `source` its URL (`https://gta.wiki/w/<Title_with_underscores>`), + `newswire` the Rockstar link in its infobox, if any. `start` is the Thursday the page names at + 2:00 AM US Pacific, `end` the day after its last day at the same time: `09:00:00Z` while the + US is on daylight saving time (mid-March to early November), `10:00:00Z` otherwise. For a week + longer than seven days, `end` follows the page's dates. +3. **Copy the sections**, standard values only. Leave out anything GTA+ only, and the daily + Junk Energy and RC Time Trials. + + | Wiki section | Field | + | ------------------------------------ | ------------------------------------------------- | + | Standard Bonuses: "3x GTA$ and RP …" | `multipliers` (`factor`, `pays`, `activity`) | + | Weekly Challenge | `challenge` (`task`, `reward`) | + | Bonuses: free items, one-off rewards | `gifts`, one line each with its deadline | + | Job Updates, Standard Bonuses, Races | `rotations` (`label`, `items`, optional `note`) | + | Discounts | `discounts` (`kind`, `item`, `percent`; free 100) | + | Job Updates: still-available events | `alsoRunning`, only those with no bonus this week | + + A multiplier that pays through a property LSOS knows gets that `property` id from + `src/data/properties.ts` (e.g. bounties: `bail-office`), so owners see it first. The Prize + Ride's challenge is on the wiki's Prize Ride Challenge page; put it in the rotation's `note`. + Keep the rotation labels of the previous week, so the tab reads the same each week. + +4. **Check.** `npm run verify 2>&1 | tail -n 5`. The type and the `weekly` test catch missing + fields, an end before the start, odd numbers and unknown property ids. +5. **Pull request** titled `Weekly events: <title>`, linking the wiki page. diff --git a/src/App.svelte b/src/App.svelte index a26b409..2cadefb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,11 +8,13 @@ import SaveStatus from './components/SaveStatus.svelte' import Settings from './components/Settings.svelte' import Timers from './components/Timers.svelte' + import Weekly from './components/Weekly.svelte' import TabBar from './components/ui/TabBar.svelte' import { GOODS } from './data/nightclub' import { PRODUCTION } from './data/production' import { PROPERTIES } from './data/properties' import { SAFES } from './data/safes' + import { WEEK } from './data/weekly' import { clocks } from './lib/clock.svelte' import { dismissPopup, overview, quick, undoQuick } from './lib/overview.svelte' import './lib/production.svelte' @@ -125,6 +127,8 @@ order={store.state.timerOrder} onorder={(order) => (store.state.timerOrder = order)} /> + {:else if active === 'weekly'} + <Weekly week={WEEK} owned={store.state.properties} now={clocks.now} /> {:else if active === 'settings'} <Settings /> {:else} diff --git a/src/components/Weekly.svelte b/src/components/Weekly.svelte new file mode 100644 index 0000000..c8b67cc --- /dev/null +++ b/src/components/Weekly.svelte @@ -0,0 +1,440 @@ +<script lang="ts"> + // The Weekly tab (issue #16, decision 027): this week's bonuses, challenge, gifts, rotation and + // discounts from `src/data/weekly.ts`, then the seasonal activities still running, kept quieter. + // Logic: lib/weekly.ts. + import { isOwned, type OwnedProperties } from '../lib/properties' + import { + discountText, + discountTiers, + sortMultipliers, + timeLeftText, + weekStatus, + type Discount, + type Week, + } from '../lib/weekly' + import Panel from './ui/Panel.svelte' + + interface Props { + week: Week + owned: OwnedProperties + /** Wall-clock time. */ + now: number + } + + let { week, owned, now }: Props = $props() + + const status = $derived(weekStatus(week, now)) + const multipliers = $derived(sortMultipliers(week.multipliers, owned)) + const KINDS: { kind: Discount['kind']; label: string }[] = [ + { kind: 'vehicle', label: 'Vehicles' }, + { kind: 'property', label: 'Properties' }, + { kind: 'weapon', label: 'Gun Van' }, + ] + const DATE = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }) +</script> + +<div class="weekly"> + <header class="head"> + <div> + <h2>{week.title}</h2> + <p class="dates"> + {DATE.format(Date.parse(week.start))} – {DATE.format(Date.parse(week.end))} + </p> + </div> + <div class="meta"> + {#if status === 'current'} + <span class="pill live">Live · {timeLeftText(Date.parse(week.end) - now)}</span> + {:else if status === 'ended'} + <span class="pill stale">Out of date</span> + {:else} + <span class="pill">Starts soon</span> + {/if} + <a href={week.source} target="_blank" rel="noopener">gta.wiki</a> + {#if week.newswire} + <a href={week.newswire} target="_blank" rel="noopener">Newswire</a> + {/if} + </div> + </header> + + {#if status === 'ended'} + <p class="warning" role="status"> + This week has ended and the new one is not in yet. The events below are last week's and may no + longer apply. + </p> + {/if} + + <div class="layout" class:ended={status === 'ended'}> + <div class="top"> + <Panel title="Bonus payouts"> + <ul class="bonuses"> + {#each multipliers as multiplier (multiplier.activity)} + {@const mine = multiplier.property !== undefined && isOwned(owned, multiplier.property)} + <li class:mine> + <span class="factor" class:high={multiplier.factor >= 3}>{multiplier.factor}x</span> + <span class="activity"> + {multiplier.activity} + {#if mine}<span class="owned-tag">Owned</span>{/if} + </span> + <span class="pays">{multiplier.pays}</span> + </li> + {:else} + <li class="quiet">No bonus payouts this week.</li> + {/each} + </ul> + </Panel> + + <Panel title="Challenge and gifts"> + {#if week.challenge} + <div class="challenge"> + <span class="kicker">Weekly Challenge</span> + <strong>{week.challenge.task}</strong> + <span class="reward">{week.challenge.reward}</span> + </div> + {/if} + {#if week.gifts.length > 0} + <ul class="gifts"> + {#each week.gifts as gift (gift)} + <li>{gift}</li> + {/each} + </ul> + {/if} + </Panel> + </div> + + <Panel title="This week's rotation"> + <div class="blocks"> + {#each week.rotations as rotation (rotation.label)} + <section class="block"> + <h3>{rotation.label}</h3> + <ul class="chips"> + {#each rotation.items as item (item)} + <li>{item}</li> + {/each} + </ul> + {#if rotation.note}<p class="note">{rotation.note}</p>{/if} + </section> + {/each} + </div> + </Panel> + + <Panel title="Discounts"> + <div class="blocks discounts"> + {#each KINDS as { kind, label } (kind)} + {@const tiers = discountTiers(week.discounts, kind)} + {#if tiers.length > 0} + <section class="block"> + <h3>{label}</h3> + {#each tiers as tier (tier.percent)} + <div class="tier"> + <span class="off" class:free={tier.percent >= 100}> + {discountText({ kind, item: '', percent: tier.percent })} + </span> + <ul class="chips"> + {#each tier.items as item (item)} + <li>{item}</li> + {/each} + </ul> + </div> + {/each} + </section> + {/if} + {/each} + </div> + </Panel> + </div> + + {#if week.alsoRunning.length > 0} + <details class="also"> + <summary>Also running, no bonus this week ({week.alsoRunning.length})</summary> + <ul class="chips"> + {#each week.alsoRunning as item (item)} + <li>{item}</li> + {/each} + </ul> + </details> + {/if} +</div> + +<style> + .weekly, + .layout { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + ul { + list-style: none; + margin: 0; + padding: 0; + } + + /* Header: title and dates on the left, status and sources on the right. */ + .head { + align-items: flex-end; + border-bottom: 1px solid var(--line); + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: space-between; + padding-bottom: var(--space-3); + } + + h2 { + font-size: var(--text-lg); + letter-spacing: var(--tracking-wide); + margin: 0; + } + + .dates { + color: var(--muted); + font-size: var(--text-sm); + margin: var(--space-1) 0 0; + } + + .meta { + align-items: center; + display: flex; + flex-wrap: wrap; + font-size: var(--text-sm); + gap: var(--space-3); + } + + .meta a { + color: var(--accent-text); + } + + .pill { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-family: var(--font-display); + font-size: var(--text-xs); + letter-spacing: var(--tracking); + padding: 0.1rem var(--space-3); + text-transform: uppercase; + } + + .pill.live { + border-color: var(--accent); + color: var(--accent-text); + } + + .pill.stale { + border-color: var(--warn); + color: var(--warn); + } + + .warning { + background: color-mix(in srgb, var(--warn) 10%, transparent); + border: 1px solid var(--warn); + border-radius: var(--radius); + color: var(--warn); + margin: 0; + padding: var(--space-2) var(--space-3); + } + + .layout.ended { + filter: grayscale(0.6); + opacity: 0.6; + } + + /* Bonus payouts (wide) next to challenge and gifts. */ + .top { + align-items: start; + display: grid; + gap: var(--space-4); + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } + + .bonuses li { + align-items: center; + border-bottom: 1px solid var(--line); + display: grid; + gap: var(--space-3); + grid-template-columns: 2.75rem 1fr auto; + padding: var(--space-2) 0; + } + + .bonuses li:last-child { + border-bottom: none; + } + + .factor { + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--accent-text); + font-family: var(--font-display); + font-weight: 600; + text-align: center; + } + + .factor.high { + background: var(--accent-deep); + } + + .pays { + color: var(--muted); + font-size: var(--text-xs); + white-space: nowrap; + } + + .mine .activity { + color: var(--accent-text); + } + + .owned-tag { + border: 1px solid var(--accent); + border-radius: var(--radius); + font-family: var(--font-display); + font-size: var(--text-xs); + letter-spacing: var(--tracking); + margin-left: var(--space-2); + padding: 0 var(--space-1); + text-transform: uppercase; + } + + .challenge { + background: var(--panel-raised); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3); + } + + .kicker, + h3 { + color: var(--muted); + font-family: var(--font-display); + font-size: var(--text-xs); + font-weight: normal; + letter-spacing: var(--tracking-wide); + margin: 0; + text-transform: uppercase; + } + + .challenge strong { + font-size: var(--text-md); + } + + .reward { + color: var(--accent-text); + font-size: var(--text-sm); + } + + .gifts { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-top: var(--space-3); + } + + .gifts li { + font-size: var(--text-sm); + padding-left: var(--space-4); + position: relative; + } + + .gifts li::before { + color: var(--accent-text); + content: '+'; + left: 0; + position: absolute; + } + + /* Rotation and discounts: labelled blocks of chips in an even grid. */ + .blocks { + display: grid; + gap: var(--space-4) var(--space-5); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); + } + + .discounts { + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); + } + + .block { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; + } + + .chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + } + + .chips li { + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + font-size: var(--text-sm); + padding: 0.1rem var(--space-2); + } + + .note { + color: var(--muted); + font-size: var(--text-xs); + margin: 0; + } + + .tier { + align-items: baseline; + display: grid; + gap: var(--space-2); + grid-template-columns: 4.5rem 1fr; + } + + .off { + color: var(--accent-text); + font-family: var(--font-display); + font-size: var(--text-sm); + letter-spacing: var(--tracking); + text-transform: uppercase; + } + + .off.free { + color: var(--warn); + } + + .quiet { + color: var(--muted); + } + + .also { + color: var(--muted); + font-size: var(--text-sm); + } + + .also summary { + cursor: pointer; + font-family: var(--font-display); + letter-spacing: var(--tracking); + text-transform: uppercase; + } + + .also .chips { + margin-top: var(--space-2); + } + + .also .chips li { + background: none; + } + + @media (max-width: 52rem) { + .top, + .discounts { + grid-template-columns: minmax(0, 1fr); + } + } +</style> diff --git a/src/data/weekly.ts b/src/data/weekly.ts new file mode 100644 index 0000000..fe0ce4b --- /dev/null +++ b/src/data/weekly.ts @@ -0,0 +1,118 @@ +// This week's GTA Online events (issue #16, decision 027), replaced by a pull request each week. +// Copied from the gta.wiki weekly event page in `source`; how to write it: docs/weekly-update.md. + +import type { Week } from '../lib/weekly' + +export const WEEK: Week = { + title: 'Halloween 2026 (Week 3)', + start: '2026-10-15T09:00:00Z', + end: '2026-10-22T09:00:00Z', + source: 'https://gta.wiki/w/Halloween_2026_(Week_3)', + newswire: + 'https://www.rockstargames.com/newswire/article/39a22k25434a53/experience-halloween-thrills-all-throughout-october-in-gta-online', + multipliers: [ + { activity: 'Ludendorff Cemetery Survival', factor: 3, pays: 'GTA$ and RP' }, + { activity: 'Cayo Perico Survival', factor: 3, pays: 'GTA$ and RP' }, + { activity: 'Alien Survivals', factor: 3, pays: 'GTA$ and RP' }, + { activity: 'Slasher', factor: 3, pays: 'GTA$ and RP' }, + { activity: 'San Andreas Super Sport Series', factor: 3, pays: 'GTA$ and RP' }, + { activity: 'Random Transform Races', factor: 3, pays: 'GTA$ and RP' }, + { activity: 'Community Race Series', factor: 3, pays: 'GTA$ and RP' }, + { + activity: 'Standard and Most Wanted Bounties', + factor: 2, + pays: 'GTA$ and RP', + property: 'bail-office', + }, + { activity: 'Dispatch Work', factor: 2, pays: 'GTA$ and RP' }, + { activity: 'UFO Business Battle', factor: 2, pays: 'GTA$ and RP' }, + { activity: "Jack O' Lanterns", factor: 2, pays: 'GTA$ and RP' }, + { activity: 'Ghosts Exposed', factor: 2, pays: 'GTA$' }, + ], + challenge: { + task: 'Win 3 Races', + reward: 'GTA$100,000 and the Orange Glow Skeleton Onesie', + }, + gifts: [ + "Complete Don't Fuck With Dre this week: GTA$500,000 within 72 hours", + 'Complete all Weekly Challenges through November 4: GTA$2,000,000 within 72 hours', + 'Free Butchery and Other Hobbies T-Shirt at any clothing store', + ], + rotations: [ + { label: 'Lucky Wheel podium', items: ['Grotti LSCM Cheetah Classic'] }, + { + label: 'Prize Ride', + items: ['Dinka Kanjo SJ'], + note: 'Place Top 5 in any LS Car Meet Series race 1 time this week', + }, + { + label: 'Salvage Yard robberies', + items: ['BF Weevil', 'Grotti Itali RSX', 'Albany Fränken Stange'], + }, + { label: 'FIB Priority File', items: ['The Brute Force File'] }, + { + label: 'Kortz Center Heist paintings', + items: ['Twindifference', 'Teckels', 'A Cast of Characters'], + }, + { + label: 'LS Car Meet Test Rides', + items: ['Declasse Vamos', 'Dewbauchee Seven-70', 'Ocelot Locust'], + }, + { label: 'Luxury Autos', items: ['Dewbauchee Rapid GT X', 'Übermacht Sentinel XS4'] }, + { + label: 'Premium Deluxe Motorsport', + items: [ + 'LCC Sanctus', + 'Albany Fränken Stange', + 'Albany Brigham', + 'Declasse Tornado Rat Rod', + 'Albany Lurcher', + ], + }, + { label: "Hao's Premium Test Ride", items: ['Maibatsu MonstroCiti'] }, + { + label: 'Gun Van stock', + items: [ + 'Baseball Bat', + 'The Shocker', + 'Service Carbine', + 'Up-n-Atomizer', + 'Unholy Hellbringer', + 'Precision Rifle', + 'Combat Shotgun', + 'El Strickler', + 'Pipe Bomb', + ], + }, + { label: 'Premium Race', items: ['Criminal Records (Super)'] }, + { label: 'Time Trial', items: ['Coast to Coast'] }, + { label: 'HSW Time Trial', items: ['Pacific Bluffs'] }, + ], + discounts: [ + { kind: 'vehicle', item: 'Albany Brigham', percent: 40 }, + { kind: 'vehicle', item: 'Albany Fränken Stange', percent: 40 }, + { kind: 'vehicle', item: 'Albany Lurcher', percent: 40 }, + { kind: 'vehicle', item: 'Declasse Tornado Rat Rod', percent: 40 }, + { kind: 'vehicle', item: 'LCC Sanctus', percent: 40 }, + { kind: 'vehicle', item: 'Annis Hardy', percent: 30 }, + { kind: 'vehicle', item: 'BF Raptor', percent: 30 }, + { kind: 'vehicle', item: 'Buckingham Miljet', percent: 30 }, + { kind: 'vehicle', item: 'Buckingham Tug', percent: 30 }, + { kind: 'vehicle', item: 'Dinka Sugoi', percent: 30 }, + { kind: 'vehicle', item: 'Karin Calico GTF', percent: 30 }, + { kind: 'vehicle', item: 'Nagasaki Weaponized Dinghy', percent: 30 }, + { kind: 'vehicle', item: 'Vapid Winky', percent: 30 }, + { kind: 'vehicle', item: 'Western Annihilator', percent: 30 }, + { kind: 'property', item: 'Agencies, upgrades and modifications', percent: 40 }, + { kind: 'weapon', item: 'Baseball Bat', percent: 100 }, + { kind: 'weapon', item: 'The Shocker', percent: 100 }, + { kind: 'weapon', item: 'Service Carbine', percent: 30 }, + ], + alsoRunning: [ + 'Cerberus Surprise', + 'Slashers Event', + 'Possessed Animals', + 'Phantom Car', + 'Peyote Plants', + ], +} diff --git a/src/lib/weekly.test.ts b/src/lib/weekly.test.ts new file mode 100644 index 0000000..ed4c6dc --- /dev/null +++ b/src/lib/weekly.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { PROPERTIES } from '../data/properties' +import { WEEK } from '../data/weekly' +import { setOwned } from './properties' +import { + discountText, + discountTiers, + sortMultipliers, + timeLeftText, + weekProblems, + weekStatus, + type Week, +} from './weekly' + +const HOUR = 60 * 60_000 +const IDS = PROPERTIES.map((property) => property.id) + +const SAMPLE: Week = { + title: 'Sample', + start: '2026-10-15T09:00:00Z', + end: '2026-10-22T09:00:00Z', + source: 'https://gta.wiki/w/Sample', + multipliers: [ + { activity: 'Dispatch Work', factor: 2, pays: 'GTA$ and RP' }, + { activity: 'Slasher', factor: 3, pays: 'GTA$ and RP' }, + { activity: 'Bounties', factor: 2, pays: 'GTA$', property: 'bail-office' }, + ], + gifts: ['Free T-shirt'], + rotations: [{ label: 'Lucky Wheel podium', items: ['Some car'] }], + discounts: [{ kind: 'weapon', item: 'Baseball Bat', percent: 100 }], + alsoRunning: [], +} + +describe('the committed week', () => { + it('has no problems', () => { + expect(weekProblems(WEEK, IDS)).toEqual([]) + }) +}) + +describe('weekStatus', () => { + it('is upcoming before the start, current until the end, then ended', () => { + expect(weekStatus(SAMPLE, Date.parse('2026-10-15T08:59:59Z'))).toBe('upcoming') + expect(weekStatus(SAMPLE, Date.parse('2026-10-15T09:00:00Z'))).toBe('current') + expect(weekStatus(SAMPLE, Date.parse('2026-10-22T08:59:59Z'))).toBe('current') + expect(weekStatus(SAMPLE, Date.parse('2026-10-22T09:00:00Z'))).toBe('ended') + }) +}) + +describe('sortMultipliers', () => { + it('puts the highest first', () => { + expect(sortMultipliers(SAMPLE.multipliers, {}).map((m) => m.activity)).toEqual([ + 'Slasher', + 'Dispatch Work', + 'Bounties', + ]) + }) + + it('puts the ones paid through an owned property ahead of everything', () => { + const owned = setOwned({}, 'bail-office', true) + expect(sortMultipliers(SAMPLE.multipliers, owned)[0]?.activity).toBe('Bounties') + }) +}) + +describe('timeLeftText', () => { + it('shows days and hours, or hours only in the last day', () => { + expect(timeLeftText((6 * 24 + 16) * HOUR + 59 * 60_000)).toBe('6d 16h left') + expect(timeLeftText(3 * HOUR + 1)).toBe('3h left') + expect(timeLeftText(-5)).toBe('0h left') + }) +}) + +describe('discountText', () => { + it('says Free at 100%', () => { + expect(discountText({ kind: 'weapon', item: 'Bat', percent: 100 })).toBe('Free') + expect(discountText({ kind: 'vehicle', item: 'Car', percent: 40 })).toBe('40% off') + }) +}) + +describe('discountTiers', () => { + it('groups one kind by percentage, highest first', () => { + expect( + discountTiers( + [ + { kind: 'vehicle', item: 'A', percent: 30 }, + { kind: 'weapon', item: 'Bat', percent: 100 }, + { kind: 'vehicle', item: 'B', percent: 40 }, + { kind: 'vehicle', item: 'C', percent: 30 }, + ], + 'vehicle', + ), + ).toEqual([ + { percent: 40, items: ['B'] }, + { percent: 30, items: ['A', 'C'] }, + ]) + }) +}) + +describe('weekProblems', () => { + it('finds what the type cannot', () => { + const bad: Week = { + ...SAMPLE, + end: SAMPLE.start, + source: 'https://example.com', + multipliers: [{ activity: 'X', factor: 1, pays: 'RP', property: 'nope' }], + discounts: [{ kind: 'vehicle', item: 'Car', percent: 0 }], + rotations: [{ label: 'Empty', items: [] }], + } + expect(weekProblems(bad, IDS)).toEqual([ + 'end is not after start', + 'multiplier "X" is not above 1', + 'multiplier "X" names unknown property "nope"', + 'discount "Car" is not 1-100%', + 'rotation "Empty" lists nothing', + 'source is not a gta.wiki page', + ]) + }) +}) diff --git a/src/lib/weekly.ts b/src/lib/weekly.ts new file mode 100644 index 0000000..59366c7 --- /dev/null +++ b/src/lib/weekly.ts @@ -0,0 +1,120 @@ +// This week's GTA Online events (issue #16, decisions 005 and 027). The week itself is a typed +// object in `src/data/weekly.ts`, replaced by a pull request each week; this file holds its shape +// and the pure logic around it. + +import type { OwnedProperties } from './properties' +import { isOwned } from './properties' + +/** One week of events, copied from its gta.wiki weekly event page. */ +export interface Week { + /** Page title on gta.wiki, e.g. "Halloween 2026 (Week 3)". */ + title: string + /** When the week starts and ends, as UTC ISO instants (Thursday 2:00 AM US Pacific). */ + start: string + end: string + /** The gta.wiki page the week was copied from. */ + source: string + /** Rockstar's Newswire article, when the page links one. */ + newswire?: string + /** Activities paying more than usual. GTA+ only values are left out. */ + multipliers: Multiplier[] + /** The Weekly Challenge, when the page names one. */ + challenge?: { task: string; reward: string } + /** Free items and one-off rewards, one line each, with any deadline in the text. */ + gifts: string[] + /** What changed this week: podium, Prize Ride, Salvage Yard, showrooms, Gun Van stock, ... */ + rotations: Rotation[] + discounts: Discount[] + /** Seasonal activities still running from earlier weeks without a bonus this week. */ + alsoRunning: string[] +} + +export interface Multiplier { + activity: string + /** 2 for 2x, 3 for 3x, ... */ + factor: number + pays: 'GTA$ and RP' | 'GTA$' | 'RP' + /** The property it pays through, if any (`src/data/properties.ts` id): owners see it first. */ + property?: string +} + +export interface Rotation { + /** What rotates, e.g. "Lucky Wheel podium". */ + label: string + items: string[] + /** What it takes, e.g. the Prize Ride's challenge. */ + note?: string +} + +export interface Discount { + kind: 'vehicle' | 'property' | 'weapon' + item: string + /** Percent off; 100 is free. */ + percent: number +} + +export type WeekStatus = 'upcoming' | 'current' | 'ended' + +/** Whether the week has started and whether it is over at `now` (ms). */ +export function weekStatus(week: Pick<Week, 'start' | 'end'>, now: number): WeekStatus { + if (now < Date.parse(week.start)) return 'upcoming' + return now < Date.parse(week.end) ? 'current' : 'ended' +} + +/** "Free" or "40% off" */ +export function discountText(discount: Discount): string { + return discount.percent >= 100 ? 'Free' : `${discount.percent}% off` +} + +/** The bonus payouts, the ones paid through an owned property first, then highest first. */ +export function sortMultipliers( + multipliers: readonly Multiplier[], + owned: OwnedProperties, +): Multiplier[] { + const ownsIt = (m: Multiplier) => m.property !== undefined && isOwned(owned, m.property) + return [...multipliers].sort( + (a, b) => Number(ownsIt(b)) - Number(ownsIt(a)) || b.factor - a.factor, + ) +} + +/** Time left in the week, e.g. "6d 16h left" or "3h left". */ +export function timeLeftText(ms: number): string { + const hours = Math.max(0, Math.floor(ms / 3_600_000)) + const days = Math.floor(hours / 24) + return days > 0 ? `${days}d ${hours % 24}h left` : `${hours}h left` +} + +/** Discounts of one kind, by percentage (highest first), keeping the file's item order. */ +export function discountTiers( + discounts: readonly Discount[], + kind: Discount['kind'], +): { percent: number; items: string[] }[] { + const tiers = new Map<number, string[]>() + for (const d of discounts) { + if (d.kind === kind) tiers.set(d.percent, [...(tiers.get(d.percent) ?? []), d.item]) + } + return [...tiers].sort(([a], [b]) => b - a).map(([percent, items]) => ({ percent, items })) +} + +/** Problems with a week's data that the type does not catch; empty when it is fine. */ +export function weekProblems(week: Week, propertyIds: readonly string[]): string[] { + const problems: string[] = [] + const start = Date.parse(week.start) + const end = Date.parse(week.end) + if (Number.isNaN(start) || Number.isNaN(end)) problems.push('start or end is not a date') + else if (end <= start) problems.push('end is not after start') + for (const m of week.multipliers) { + if (!(m.factor > 1)) problems.push(`multiplier "${m.activity}" is not above 1`) + if (m.property !== undefined && !propertyIds.includes(m.property)) { + problems.push(`multiplier "${m.activity}" names unknown property "${m.property}"`) + } + } + for (const d of week.discounts) { + if (!(d.percent > 0 && d.percent <= 100)) problems.push(`discount "${d.item}" is not 1-100%`) + } + for (const r of week.rotations) { + if (r.items.length === 0) problems.push(`rotation "${r.label}" lists nothing`) + } + if (!/^https:\/\/gta\.wiki\//.test(week.source)) problems.push('source is not a gta.wiki page') + return problems +}