From f760d58568332234ddc07d4a59a009215197c25e Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:48:22 -0400 Subject: [PATCH 01/17] allowing more active sessions to show on active-sessions page --- package.json | 6 +- scripts/verify-active-session-limit.ts | 142 +++++++++++++++++++++++++ src/app/active-sessions/page.tsx | 2 +- src/app/api/active-sessions/route.ts | 67 ++++++++---- src/lib/active-session/dedupe.ts | 57 +++++++--- src/lib/db/queries.ts | 25 ++++- 6 files changed, 258 insertions(+), 41 deletions(-) create mode 100644 scripts/verify-active-session-limit.ts diff --git a/package.json b/package.json index 58b2fdc..2f67e92 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "crawler": "npx tsx scripts/start-crawler.ts", "setup-manifest": "npx tsx scripts/setup-manifest.ts", "db-stats": "npx tsx scripts/db-stats.ts", - "debug-players": "npx tsx scripts/debug-players.ts", - "debug-sessions": "npx tsx scripts/debug-sessions.ts", + "debug-players": "npx tsx scripts/cleanup/debug-players.ts", + "debug-sessions": "npx tsx scripts/cleanup/debug-sessions.ts", "scanner": "npx tsx scripts/start-scanner.ts", "db-maintenance": "npx tsx scripts/run-db-maintenance.ts", "backfill-ended-at": "npx tsx scripts/backfill-ended-at.ts", @@ -25,7 +25,7 @@ "create-phase3-indexes": "npx tsx scripts/create-phase3-indexes.ts", "verify-phase3-cutover": "npx tsx scripts/verify-phase3-cutover.ts", "drop-phase3-orphan-indexes": "npx tsx scripts/drop-phase3-orphan-indexes.ts", - "cleanup-types": "npx tsx scripts/cleanup-membership-types.ts", + "cleanup-types": "npx tsx scripts/cleanup/cleanup-membership-types.ts", "test-maintenance-cycle": "npx tsx scripts/test-maintenance-cycle.ts", "find-private": "npx tsx scripts/find-private-session-players.ts" }, diff --git a/scripts/verify-active-session-limit.ts b/scripts/verify-active-session-limit.ts new file mode 100644 index 0000000..8ea2bdb --- /dev/null +++ b/scripts/verify-active-session-limit.ts @@ -0,0 +1,142 @@ +/** + * Verification for the fireteam-denominated active-session display cap. + * + * npx tsx scripts/verify-active-session-limit.ts + * + * Read-only — safe against the dev DB. Requires the crawler to be running (or to have run + * within the last 900s), since the active-session freshness window is 15 minutes. + * + * Reports the old row-denominated behaviour against the new fireteam-denominated one: + * how many fireteams each surfaces, and how old the oldest visible session is. The old + * behaviour's headline failure was that the oldest visible session was only minutes old. + */ +import { getDb } from '../src/lib/db'; +import { + ACTIVE_SESSION_DISPLAY_LIMIT, + compareSessionsForDisplay, + dedupeActiveSessions, + getDedupedActiveSessions, +} from '../src/lib/active-session/dedupe'; +import { ACTIVE_SESSION_ROW_SCAN_LIMIT, type ActiveSessionDbRow } from '../src/lib/db/queries'; + +const OLD_ROW_LIMIT = 200; +const FRESHNESS_SECONDS = 900; + +function memberIds(partyMembersJson: string | null): string[] { + if (!partyMembersJson) return []; + try { + const parsed = JSON.parse(partyMembersJson) as Array<{ membershipId?: unknown }>; + return parsed.map((m) => String(m?.membershipId || '')).filter(Boolean); + } catch { + return []; + } +} + +function ageMinutes(startedAt: string): number { + const started = Date.parse(startedAt); + if (Number.isNaN(started)) return 0; + return Math.round((Date.now() - started) / 60_000); +} + +function oldestVisibleMinutes(sessions: ActiveSessionDbRow[]): number { + return sessions.reduce((max, s) => Math.max(max, ageMinutes(s.startedAt)), 0); +} + +const db = getDb(); +const cutoff = Math.floor(Date.now() / 1000) - FRESHNESS_SECONDS; + +const freshRaidRows = (db.prepare(` + SELECT COUNT(*) AS c FROM active_sessions + WHERE checked_at >= ? AND (activity_mode_type = 4 OR raid_key IS NOT NULL) +`).get(cutoff) as { c: number }).c; + +if (freshRaidRows === 0) { + console.error( + 'No fresh raid rows in the last 900s. Start the crawler (npm run crawler) and retry.\n' + + 'On WSL2, also check the system clock has not drifted — that alone can empty this window.' + ); + process.exit(1); +} + +// OLD behaviour: cap the raw per-player rows, ordered by started_at DESC, then dedupe. +const oldRows = db.prepare(` + SELECT membership_id AS membershipId, membership_type AS membershipType, display_name AS displayName, + activity_hash AS activityHash, activity_mode_hash AS activityModeHash, + activity_mode_type AS activityModeType, raid_key AS raidKey, started_at AS startedAt, + party_members_json AS partyMembersJson, player_count AS playerCount, checked_at AS checkedAt + FROM active_sessions + WHERE checked_at >= ? AND (activity_mode_type = 4 OR raid_key IS NOT NULL) + ORDER BY started_at DESC LIMIT ? +`).all(cutoff, OLD_ROW_LIMIT) as ActiveSessionDbRow[]; + +const oldFireteams = dedupeActiveSessions(oldRows, (row) => ({ + activityHash: row.activityHash, + memberIds: memberIds(row.partyMembersJson), + checkedAt: row.checkedAt, + startedAt: row.startedAt, +})); + +// NEW behaviour: scan up to the row bound, dedupe, sort for display, then cap in fireteams. +const startedAt = Date.now(); +const newFireteams = getDedupedActiveSessions(); +const dedupeMs = Date.now() - startedAt; + +const rosterSize = (session: ActiveSessionDbRow): number => + new Set(memberIds(session.partyMembersJson)).size; + +newFireteams.sort((a, b) => compareSessionsForDisplay( + { memberCount: rosterSize(a), startedAt: a.startedAt }, + { memberCount: rosterSize(b), startedAt: b.startedAt } +)); +const shown = newFireteams.slice(0, ACTIVE_SESSION_DISPLAY_LIMIT); + +console.log(`\nfresh raid rows in window: ${freshRaidRows}`); +console.log(`row scan limit: ${ACTIVE_SESSION_ROW_SCAN_LIMIT} display limit: ${ACTIVE_SESSION_DISPLAY_LIMIT} fireteams`); +console.log(`dedupe cost: ${dedupeMs}ms\n`); + +console.log(' rows scanned fireteams oldest visible'); +console.log(` OLD (LIMIT ${OLD_ROW_LIMIT} rows) ${String(oldRows.length).padStart(12)} ${String(oldFireteams.length).padStart(9)} ${oldestVisibleMinutes(oldFireteams)}m`); +console.log(` NEW (fireteam cap) ${String(freshRaidRows).padStart(12)} ${String(shown.length).padStart(9)} ${oldestVisibleMinutes(shown)}m`); + +const hist = new Map(); +for (const session of newFireteams) { + const size = rosterSize(session); + hist.set(size, (hist.get(size) || 0) + 1); +} +console.log('\nroster sizes across all live fireteams:'); +for (const size of [...hist.keys()].sort((a, b) => a - b)) { + console.log(` ${size} member${size === 1 ? ' ' : 's'}: ${hist.get(size)}`); +} + +// Decision 4: no multi-member fireteam may appear after a single-member one. +const firstSolo = newFireteams.findIndex((s) => rosterSize(s) <= 1); +const soloOrderingHolds = firstSolo < 0 + || newFireteams.slice(firstSolo).every((s) => rosterSize(s) <= 1); + +// Within each tier, newest first. +const recencyHolds = newFireteams.every((session, i) => { + if (i === 0) return true; + const prev = newFireteams[i - 1]; + if ((rosterSize(prev) > 1) !== (rosterSize(session) > 1)) return true; // tier boundary + return (Date.parse(prev.startedAt) || 0) >= (Date.parse(session.startedAt) || 0); +}); + +let failed = 0; +const check = (ok: boolean, label: string): void => { + if (!ok) failed++; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}`); +}; + +console.log('\nchecks:'); +check(newFireteams.length >= oldFireteams.length, 'new surfaces at least as many fireteams as old'); +check(oldestVisibleMinutes(shown) >= oldestVisibleMinutes(oldFireteams), 'new surfaces sessions at least as old as old'); +check(soloOrderingHolds, 'single-member sessions sort below real fireteams'); +check(recencyHolds, 'within each tier, newest first'); +console.log( + newFireteams.length > ACTIVE_SESSION_DISPLAY_LIMIT + ? ' INFO display cap is biting — check server logs for the warning' + : ' INFO display cap not biting' +); + +console.log(''); +process.exit(failed > 0 ? 1 : 0); diff --git a/src/app/active-sessions/page.tsx b/src/app/active-sessions/page.tsx index b25d82b..a3ed3ac 100644 --- a/src/app/active-sessions/page.tsx +++ b/src/app/active-sessions/page.tsx @@ -55,7 +55,7 @@ export default function ActiveSessionsPage() { const fetchSessions = useCallback(async () => { setLoading(true); try { - const response = await fetch('/api/active-sessions?limit=200'); + const response = await fetch('/api/active-sessions?limit=600'); if (!response.ok) throw new Error(`API error: ${response.status}`); const data = await response.json(); setSessions(data.sessions || []); diff --git a/src/app/api/active-sessions/route.ts b/src/app/api/active-sessions/route.ts index 6ed8b95..7008a86 100644 --- a/src/app/api/active-sessions/route.ts +++ b/src/app/api/active-sessions/route.ts @@ -1,9 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { formatBungieDisplayName, getActiveSessions } from '@/lib/db/queries'; +import { formatBungieDisplayName } from '@/lib/db/queries'; import { getAllRaidDefinitions } from '@/lib/bungie/manifest'; import { getDb, isDatabaseMaintenanceError } from '@/lib/db'; import { getActivityDisplayName } from '@/lib/utils/activity'; -import { dedupeActiveSessions } from '@/lib/active-session/dedupe'; +import { + ACTIVE_SESSION_DISPLAY_LIMIT, + compareSessionsForDisplay, + getDedupedActiveSessions, +} from '@/lib/active-session/dedupe'; import { withCache, withNoStore } from '@/lib/http/cache'; interface PlayerLookupRow { @@ -56,7 +60,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const raidKey = searchParams.get('raid') || undefined; - const limit = parseInt(searchParams.get('limit') || '50', 10); + const limit = parseInt(searchParams.get('limit') || String(ACTIVE_SESSION_DISPLAY_LIMIT), 10); if (raidKey) { const raids = getAllRaidDefinitions(); @@ -68,31 +72,50 @@ export async function GET(request: NextRequest) { } } - if (limit < 1 || limit > 200) { + // Number.isInteger rejects a non-numeric `limit`: parseInt returns NaN, and every NaN + // comparison is false, so a bare range check would let it through and slice(0, NaN) + // would return zero sessions while reporting a non-zero total. + if (!Number.isInteger(limit) || limit < 1 || limit > ACTIVE_SESSION_DISPLAY_LIMIT) { return withNoStore(NextResponse.json( - { error: 'limit must be between 1 and 200' }, + { error: `limit must be between 1 and ${ACTIVE_SESSION_DISPLAY_LIMIT}` }, { status: 400 } )); } try { - const rawSessions = getActiveSessions(raidKey, limit, true); + // Dedupe into fireteams FIRST, then enrich only the survivors. Enriching before dedupe + // meant looking up names for ~6000 membership ids to render ~250 cards, and pushed the + // IN(...) clause toward SQLite's bind-parameter ceiling. + const dedupedSessions = getDedupedActiveSessions(raidKey); const db = getDb(); - // Build a set of all membership IDs across all sessions - const allMembershipIds = new Set(); - const parsedSessions: Array<{ raw: (typeof rawSessions)[number]; partyMembers: ParsedPartyMember[] }> = []; + const parsedAll = dedupedSessions.map((raw) => ({ + raw, + partyMembers: parsePartyMembers(raw.partyMembersJson), + })); - for (const session of rawSessions) { - const partyMembers = parsePartyMembers(session.partyMembersJson); + parsedAll.sort((a, b) => compareSessionsForDisplay( + { memberCount: a.partyMembers.length, startedAt: a.raw.startedAt }, + { memberCount: b.partyMembers.length, startedAt: b.raw.startedAt } + )); + + const total = parsedAll.length; + if (total > limit) { + console.warn( + `[SESSIONS] Display cap hit: ${total} live fireteams, showing ${limit}.` + + ` Raise ACTIVE_SESSION_DISPLAY_LIMIT (currently ${ACTIVE_SESSION_DISPLAY_LIMIT}) if this persists.` + ); + } + const parsedSessions = parsedAll.slice(0, limit); + // Build a set of membership IDs across the sessions we will actually render + const allMembershipIds = new Set(); + for (const { partyMembers } of parsedSessions) { for (const member of partyMembers) { if (member.membershipId) { allMembershipIds.add(member.membershipId); } } - - parsedSessions.push({ raw: session, partyMembers }); } // Batch lookup display names from the players table @@ -164,23 +187,23 @@ export async function GET(request: NextRequest) { }; }); - const deduped = dedupeActiveSessions(sessions, (session) => ({ - activityHash: session.activityHash, - memberIds: session.partyMembers.map((m) => m.membershipId), - checkedAt: session.checkedAt, - startedAt: session.startedAt, - })); - + // Already deduped upstream — `sessions` is one entry per fireteam. return withCache(NextResponse.json({ raidKey: raidKey || 'all', - count: deduped.length, - sessions: deduped, + count: sessions.length, + // `total` is every live fireteam; `shown` is how many fit under the display cap. + // They differ only when the cap bites, which the page can surface to the user. + total, + shown: sessions.length, + sessions, }), 10, 30); } catch (error) { if (isDatabaseMaintenanceError(error)) { return withNoStore(NextResponse.json({ raidKey: raidKey || 'all', count: 0, + total: 0, + shown: 0, sessions: [], maintenance: true, message: 'Database maintenance is in progress. Active sessions are temporarily unavailable.', diff --git a/src/lib/active-session/dedupe.ts b/src/lib/active-session/dedupe.ts index b91ba4f..25dfe79 100644 --- a/src/lib/active-session/dedupe.ts +++ b/src/lib/active-session/dedupe.ts @@ -4,7 +4,7 @@ // instance_id), so a fireteam with N tracked members yields N rows for the SAME session. // Both the /active-sessions API (display list) and the OG cards (live count) must collapse // these the same way, or the numbers drift. -import { getActiveSessions } from '../db/queries'; +import { getActiveSessions, type ActiveSessionDbRow } from '../db/queries'; /** Minimal fields needed to decide whether two rows are the same fireteam session. */ export interface DedupeKey { @@ -93,21 +93,54 @@ function parseMemberIds(partyMembersJson: string | null | undefined): string[] { } } +// Maximum fireteams rendered on the page. Denominated in FIRETEAMS, not rows — the previous +// row-denominated limit collapsed to ~110 cards and hid every long-running raid. See docs/adr/0001. +export const ACTIVE_SESSION_DISPLAY_LIMIT = Math.max( + 1, + parseInt(process.env.ACTIVE_SESSION_DISPLAY_LIMIT || '600', 10) +); + +/** + * Every distinct active raid fireteam currently live, de-duped from the per-player rows. + * Single source of truth for both the /active-sessions list and the headline count, so the + * two can't drift. Bounded only by the row-scan limit — the *display* cap is applied by the + * caller, after sorting, so no caller can accidentally truncate by row again. + */ +export function getDedupedActiveSessions(raidKey?: string): ActiveSessionDbRow[] { + const rows = getActiveSessions(raidKey, undefined, true); + return dedupeActiveSessions(rows, (row) => ({ + activityHash: row.activityHash, + memberIds: parseMemberIds(row.partyMembersJson), + checkedAt: row.checkedAt, + startedAt: row.startedAt, + })); +} + +/** + * Display ordering for the active-sessions list: fireteams with more than one visible member + * first, then newest first. A single-member session is usually a transitory-visibility artifact + * (we couldn't read the fireteam) rather than a genuine solo run, so it ranks below real + * fireteams — and is the first thing dropped if the display cap bites. + */ +export function compareSessionsForDisplay( + a: { memberCount: number; startedAt: string }, + b: { memberCount: number; startedAt: string } +): number { + const aSolo = a.memberCount > 1 ? 0 : 1; + const bSolo = b.memberCount > 1 ? 0 : 1; + if (aSolo !== bSolo) return aSolo - bSolo; + return (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0); +} + /** - * Accurate count of distinct active raid fireteams (de-duped). This is the number the - * /active-sessions page displays; OG cards use it so their count matches. Returns 0 on a - * database-maintenance error rather than throwing. + * Count of distinct active raid fireteams, used by the StatsBar and the OG share cards. + * This is the TRUE total and may legitimately exceed the number of cards the page renders + * (which is capped) — it answers "how busy is Destiny right now". See docs/adr/0002. + * Returns 0 on a database-maintenance error rather than throwing. */ export function countActiveRaidSessions(): number { try { - const rows = getActiveSessions(undefined, 200, true); - const deduped = dedupeActiveSessions(rows, (row) => ({ - activityHash: row.activityHash, - memberIds: parseMemberIds(row.partyMembersJson), - checkedAt: row.checkedAt, - startedAt: row.startedAt, - })); - return deduped.length; + return getDedupedActiveSessions().length; } catch { return 0; } diff --git a/src/lib/db/queries.ts b/src/lib/db/queries.ts index a7499c4..5445a22 100644 --- a/src/lib/db/queries.ts +++ b/src/lib/db/queries.ts @@ -1184,7 +1184,21 @@ export function upsertActiveSession(session: { ); } -export function getActiveSessions(raidKey?: string, limit: number = 50, onlyRaidMode: boolean = true): ActiveSessionDbRow[] { +// How many raw per-player rows an active-session read may scan. This is NOT the display limit: +// `active_sessions` is keyed by membership_id, so one fireteam yields up to 6 rows, and the +// user-facing cap is denominated in *fireteams* after dedupe (see active-session/dedupe.ts). +// Sized at ~2x the ceiling the crawler can produce: at CRAWLER_SESSION_POLLING_LIMIT rows per +// cycle over the 900s freshness window, at most ~1500 rows can be fresh at once. +export const ACTIVE_SESSION_ROW_SCAN_LIMIT = Math.max( + 1, + parseInt(process.env.ACTIVE_SESSION_ROW_SCAN_LIMIT || '3000', 10) +); + +export function getActiveSessions( + raidKey?: string, + rowScanLimit: number = ACTIVE_SESSION_ROW_SCAN_LIMIT, + onlyRaidMode: boolean = true +): ActiveSessionDbRow[] { const db = getDb(); // Only show sessions checked within the last 15 minutes @@ -1218,8 +1232,13 @@ export function getActiveSessions(raidKey?: string, limit: number = 50, onlyRaid queryParams.push(raidKey); } - query += ` ORDER BY started_at DESC LIMIT ?`; - queryParams.push(limit); + // Ordered by checked_at (indexed by idx_active_sessions_checked_at), NOT started_at. Callers + // dedupe into fireteams and apply their own display sort, so this ordering only decides which + // rows survive if `rowScanLimit` is ever hit — and then we want to shed the *stalest* rows, + // which are closest to ageing out anyway. Ordering by started_at here is what caused + // long-running raids to be silently dropped; see docs/adr/0001. + query += ` ORDER BY checked_at DESC LIMIT ?`; + queryParams.push(rowScanLimit); return db.prepare(query).all(...queryParams) as ActiveSessionDbRow[]; } From 0e924517983585f53e8cb2f93a3197a43903389d Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:25:41 -0400 Subject: [PATCH 02/17] chore: commit pending transitive dependency bumps Lockfile churn from a prior `npm install` that resolved newer patch versions of existing dev dependencies (eslint internals and friends). Isolated in its own commit so it stays separable from the Vitest install that follows. --- package-lock.json | 94 +++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33a207c..731b6e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -783,15 +783,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -824,9 +824,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", - "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -836,8 +836,8 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.3", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -848,9 +848,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -3232,16 +3232,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -3829,9 +3829,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4207,9 +4207,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4965,25 +4965,25 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -5002,7 +5002,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5468,9 +5468,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -5782,15 +5782,15 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -6582,9 +6582,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -7170,9 +7170,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -7665,9 +7665,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -7685,7 +7685,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, From ec414906541ccebf4a1acca12666cedb2dc79055 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:36:38 -0400 Subject: [PATCH 03/17] docs: record the agreed testing-framework plan Captures the recon findings that contradicted the original brief (the ended_at cutover already shipped; isFullClear is dead code; the leaderboard SQL lives in leaderboard-cache.ts; request() has no retry logic) along with the ten decisions taken in response. Written down because the commits that follow will not match the brief they came from, and the reason should not live only in a chat log. --- docs/testing-framework-plan.md | 171 +++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/testing-framework-plan.md diff --git a/docs/testing-framework-plan.md b/docs/testing-framework-plan.md new file mode 100644 index 0000000..02de141 --- /dev/null +++ b/docs/testing-framework-plan.md @@ -0,0 +1,171 @@ +# Testing framework — plan of record + +**Branch:** `test-framework` +**Date:** 2026-07-26 +**Supersedes:** the original `testingframeworksetupplan.md` brief, in the four places noted below. + +This is the agreed plan after a recon pass over the repo and a decision-by-decision review. It +exists because the original brief was written against assumptions that the code no longer holds, +and someone reading the resulting commits will otherwise wonder why the phases don't match the +brief they were given. + +--- + +## 1. Where the repo contradicted the brief + +The brief instructed: *"Do not guess at any of the above. If something contradicts this document, +trust the repo and tell me."* Four of its seven phases rested on premises that no longer hold. + +### 1.1 Phase 5 had nothing left to protect + +The brief called the `ended_at` cutover "in-flight" and Phase 5 "the highest-value part of the +task" — a parity net for roughly ten SQL sites still using the `run_durations` CTE. + +The cutover already shipped, in `610408e` *"leaderboard denormalization phase 3b — readers +cutover, drop dead indexes"*. There are **zero** `run_durations` references anywhere in `src/`. +The only survivors are inside `scripts/verify-phase3-cutover.ts`, the one-off parity script that +already performed exactly this comparison against the production database. + +### 1.2 Phase 3's centerpiece is dead code — and would be wrong if used + +The brief identified `processPGCR`'s three-way `||` on `isFullClear` as "the riskiest logic in the +codebase" and devoted 5 of its 11 Phase 3 cases to it. + +`ProcessedPGCR.isFullClear` is computed at `src/lib/crawler/pgcr.ts:36`, returned, and **never +read by anything**. `fetchAndStorePGCR` persists `pgcrData.activityWasStartedFromBeginning` — the +raw Bungie field — and every leaderboard filters on that column instead +(`leaderboard-cache.ts:175`, `queries.ts:760/781/820/855`). + +That matters more than "unused code," because the local database shows the derivation is also +wrong. Across 827,076 rows: + +| Field | Distribution | +|---|---| +| `starting_phase_index` | `0` → 827,076 (**100%**) | +| `activity_was_started_from_beginning` | `0` → 568,648 · `1` → 258,426 | +| `completed` | `0` → 456,009 · `1` → 371,070 | + +Bungie has stopped sending `startingPhaseIndex` entirely. So the `startingPhaseIndex === undefined` +branch fires unconditionally and `isFullClear` is `true` for **100%** of runs — including the +568,648 that are genuinely not full clears. It is inert only because nothing consumes it. Wired +up, it would inflate every leaderboard by roughly 2.2×. + +Consequence for fixtures: the brief's requested "checkpoint run (`startingPhaseIndex > 0`)" +fixture **cannot be captured**, because no such row exists in 827k records. + +### 1.3 Phase 4 pointed at the wrong file + +The leaderboard SQL is `runLeaderboardRows` in `src/lib/cache/leaderboard-cache.ts:146`, not +`src/lib/db/queries.ts`. Two of the brief's bullets don't apply: `fullClearsOnly` is forced `true` +on every code path (there is no `false` branch to test), and the `run_durations` zero-completion +case is obsolete per §1.1. + +The real query has edges the brief never mentions — a three-key tie-break +(`completions DESC, lastClearAt ASC, membership_id ASC`), competition-style rank assignment across +tie groups, a `LEFT JOIN players` name fallback, and `formatDisplayName`'s `padStart(4, '0')`. + +### 1.4 Phase 6 has no retry logic + +The brief asked for "retry/backoff behavior, using Vitest fake timers rather than real sleeps." +`BungieClient.request()` contains no retry — it classifies errors and pauses a shared rate limiter, +then throws. The real fake-timer target is `RateLimiter` (`src/lib/utils/rate-limiter.ts`), a FIFO +promise chain with a subtle mid-sleep `pauseFor` re-read. + +Also, `isBungieSystemDisabledError` is two lines (`maintenance.ts:259`), not a subsystem. + +### 1.5 What the brief got right + +- `getDb()` **is** already injectable, via `RAID_TRACKER_DB_PATH` (`db/index.ts:7`). +- Schema creation **is** programmatic: `initializeSchema()` in `src/lib/db/schema.ts`, invoked by + `getDb()`. `ended_at` arrives through an `ALTER TABLE` migration guard (`schema.ts:120-123`), so + a fresh database gets the column and the Phase-3 indexes automatically. +- `/coverage` is already gitignored. `npm run lint` passes clean. Local Node is v22.18.0. +- `processPGCR`'s signature and shape match the brief exactly. + +**No application code changes are required by any phase.** + +One further correction, outside the brief: raid detection does **not** read +`data/manifest-cache.json` at runtime. `RAID_DEFINITIONS` is a hardcoded literal in +`src/lib/bungie/manifest.ts:16`; the cache file is only *written* by `setup-manifest` for human +review. This makes `isRaidActivityHash` hermetic, which is good for tests, but CLAUDE.md is +misleading on the point. + +--- + +## 2. Decisions + +| # | Area | Decision | +|---|---|---| +| 1 | Phase 5 | Retarget to `computeActivityDurationSeconds` — the three-tier duration fallback and the `FUTURE_ENDED_SKEW_SECONDS` corruption guard — asserted end-to-end through `insertFullPGCR`. No parity testing against the removed CTE. | +| 2 | Phase 3 | Test the **persisted** full-clear signal, not `isFullClear`. The dead field is documented for removal in a future change, not removed on this branch. | +| 3 | Fixtures | A committed capture script, seeded with instance IDs pulled from the local database, is run **by the maintainer**. `.env` is never read. | +| 4 | Test DB | `makeTestDb()` uses a per-file `mkdtemp` directory via `RAID_TRACKER_DB_PATH`. See ADR 0003. | +| 5 | Phase 4 | Surviving brief bullets, plus the query's real edges, plus the SQL-vs-JS boundary. | +| 6 | Phase 6 | The predicate, `request()`'s error dispatch behind a stubbed `fetch`, and `RateLimiter` under fake timers. Plus a global guard against unstubbed outbound requests. | +| 7 | CI | `npm ci` · `npm run lint` · `tsc --noEmit` · `npm test`, on Node 22. Typecheck added because nothing else catches type errors without a full `next build`. | +| 8 | Commits | `package-lock.json` isolated in its own commit first, then one scoped commit per phase. Pre-existing untracked files left untouched. | +| 9 | Glossary | Sharpen **Full Clear**; add **Checkpoint Run** and **Completion**. | +| 10 | Records | ADR 0003 (test-database strategy), ADR 0004 (testing policy), and a `docs/decisions.md` entry. | + +### Why the test database is a temp file, not `:memory:` + +Verified empirically rather than assumed: + +``` +:memory: journal_mode = WAL -> 'memory' (silently ignored) +file journal_mode = WAL -> 'wal' +``` + +SQLite cannot put an in-memory database into WAL mode, so `:memory:` would exercise different +journal semantics than production — undercutting the entire premise that a real database +"actually validates the SQL." A temp path also isolates `DATA_DIR` for free, because it derives +from `dirname(RAID_TRACKER_DB_PATH)` (`maintenance/state.ts:4-8`). That matters: `getDb()` calls +`isDbQuiesceActive()` on **every** invocation, which reads `data/maintenance-state.json` from +disk — so a suite pointed at the real data directory would fail every test with +`DatabaseMaintenanceError` if run during a maintenance vacuum. + +Cost is a `mkdtemp` plus schema init per test file, roughly 5–15 ms on tmpfs. At this suite size +the speed argument for `:memory:` does not survive the numbers. + +--- + +## 3. Execution order + +**Phase 1 — install and wire up Vitest.** +`vitest.config.ts`, npm scripts, and the `test-maintenance-cycle` → `e2e:maintenance` rename with +every reference updated. +*Gate: demonstrate a passing run, then a deliberately broken assertion failing with a readable +diff, then delete the throwaway. **Stop and report.*** + +**Phase 2 — fixtures.** +Commit the capture script. ***Forced pause: the maintainer runs it.*** Then the builders in +`tests/helpers/` and `tests/fixtures/README.md`. + +**Phases 3 → 7 — run straight through**, one scoped commit each, then the docs commit. + +Two mandatory stops: the Phase 1 gate, and the fixture capture. + +--- + +## 4. Out of scope + +No React, DOM, or jsdom testing. No Playwright. No coverage thresholds or gates — the reporter is +installed, no number is enforced. No mocking of our own modules; the network boundary only. No +tests against the real database file or the real Bungie API. `scripts/test-maintenance-cycle.ts` +is not ported, rewritten, or absorbed — it changes only by script name. No cutover is performed. +The `isFullClear` defect is reported, not fixed. + +--- + +## 5. Findings to report, not fix + +1. **`ProcessedPGCR.isFullClear` is dead and would be wrong if used.** See §1.2. Flagged for + removal in a future change. +2. **`formatDisplayName` drops the `#code` when the code is falsy** + (`leaderboard-cache.ts:135`), contradicting the `Name#Code` invariant CLAUDE.md calls + load-bearing. +3. **`getDb()` reads `maintenance-state.json` from disk on every call**, not just on open. +4. **CLAUDE.md's raid-detection description is inaccurate** — see the note at the end of §1.5. +5. **55% of stored PGCRs (456,009 of 827,076) have zero completed players.** Not a defect on its + own, but it is the dominant shape in the table and worth knowing when reasoning about any + query that joins through completions. From 039a93ccd804bfa566766d7fe10988d984fae227 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:36:38 -0400 Subject: [PATCH 04/17] test: bootstrap vitest (phase 1) Vitest over Jest: it runs TypeScript and ESM natively with no babel or ts-jest layer, reads the existing tsconfig's path aliases, and does not contend with Next.js 16's bundler. - vitest.config.ts: node environment, colocated src/**/*.test.ts plus tests/**/*.test.ts, explicit @/ -> src/ alias mirroring tsconfig, v8 coverage reporter with no thresholds. - npm test / test:watch / test:coverage. - Renamed test-maintenance-cycle -> e2e:maintenance (same command) so `npm test` means fast, hermetic, no network and nothing else. The only other references to the old name are in untracked .codex/ transcripts. - Global network guard fails any test reaching the real internet without stubbing fetch, with its own tests covering the re-arm-after-stub case. Gate verified: a passing run, then a flipped assertion failing with a readable diff, then the throwaway deleted. eslint and tsc --noEmit both clean on the new files; no flat-config override was needed. --- package-lock.json | 1338 ++++++++++++++++++++++++++++++-- package.json | 10 +- tests/setup/no-network.test.ts | 35 + tests/setup/no-network.ts | 29 + vitest.config.ts | 37 + 5 files changed, 1395 insertions(+), 54 deletions(-) create mode 100644 tests/setup/no-network.test.ts create mode 100644 tests/setup/no-network.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 731b6e3..b2ba0c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", "tsx": "^4.21.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -265,22 +267,32 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -288,9 +300,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1758,6 +1770,317 @@ "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", @@ -2662,6 +2985,13 @@ "webpack": ">=5.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2943,9 +3273,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -2963,6 +3293,24 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3597,6 +3945,160 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -4080,6 +4582,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4087,6 +4599,35 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4374,6 +4915,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4837,8 +5388,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -5417,6 +5967,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5967,6 +6527,13 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -6516,6 +7083,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -7044,6 +7650,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7496,6 +8143,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7635,6 +8296,13 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7983,6 +8651,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -8404,6 +9106,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -8486,6 +9195,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stacktrace-parser": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", @@ -8498,6 +9214,13 @@ "node": ">=6" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8807,15 +9530,32 @@ } } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -8855,6 +9595,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9194,42 +9944,509 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", @@ -9411,6 +10628,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 2f67e92..25bb59f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,11 @@ "verify-phase3-cutover": "npx tsx scripts/verify-phase3-cutover.ts", "drop-phase3-orphan-indexes": "npx tsx scripts/drop-phase3-orphan-indexes.ts", "cleanup-types": "npx tsx scripts/cleanup/cleanup-membership-types.ts", - "test-maintenance-cycle": "npx tsx scripts/test-maintenance-cycle.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "e2e:maintenance": "npx tsx scripts/test-maintenance-cycle.ts", + "capture-fixtures": "npx tsx scripts/capture-pgcr-fixture.ts", "find-private": "npx tsx scripts/find-private-session-players.ts" }, "dependencies": { @@ -43,10 +47,12 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", "tsx": "^4.21.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } diff --git a/tests/setup/no-network.test.ts b/tests/setup/no-network.test.ts new file mode 100644 index 0000000..31b31ea --- /dev/null +++ b/tests/setup/no-network.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Tests the guard in ./no-network.ts. A silently-broken network guard is worse +// than none: the suite would look protected while quietly hitting the real +// Bungie API, burning quota and going flaky against live data. +describe('the global network guard', () => { + it('rejects a fetch that no test has stubbed', async () => { + await expect(fetch('https://stats.bungie.net/Platform/Destiny2/')).rejects.toThrow( + /Blocked an unstubbed network request/ + ); + }); + + it('names the blocked URL so the offending call is findable', async () => { + await expect(fetch('https://www.bungie.net/some/path')).rejects.toThrow( + 'https://www.bungie.net/some/path' + ); + }); + + it('steps aside for a test that stubs fetch deliberately', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"ok":true}'))); + + const response = await fetch('https://stats.bungie.net/Platform/Destiny2/'); + + expect(await response.text()).toBe('{"ok":true}'); + }); + + it('is back in force for the test after a stub', async () => { + // Guards against a leaked stub from the previous test: the beforeEach in + // no-network.ts must re-arm the thrower even though vi.stubGlobal was + // called and never explicitly unstubbed. + await expect(fetch('https://stats.bungie.net/Platform/Destiny2/')).rejects.toThrow( + /Blocked an unstubbed network request/ + ); + }); +}); diff --git a/tests/setup/no-network.ts b/tests/setup/no-network.ts new file mode 100644 index 0000000..6e747e2 --- /dev/null +++ b/tests/setup/no-network.ts @@ -0,0 +1,29 @@ +import { beforeEach, vi } from 'vitest'; + +/** + * Global network guard. + * + * Every outbound call in this codebase goes through `fetch` — the Bungie client + * (`src/lib/bungie/client.ts`) and the manifest downloader are the only callers. + * So replacing `fetch` with a thrower is sufficient to catch a test that reaches + * the real internet, which would burn Bungie API quota and make the suite flaky. + * + * A test that legitimately needs `fetch` stubs it with `vi.stubGlobal('fetch', …)`. + * That records this thrower as the original and restores it afterwards, so the + * guard is back in place for the next test without any per-file cleanup. + * + * Scope note: this does not intercept `node:http`/`node:https` directly. Nothing + * in `src/` uses them, and adding an http-module shim would be machinery guarding + * a door nobody walks through. + */ +beforeEach(() => { + vi.stubGlobal('fetch', (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : String(input); + return Promise.reject( + new Error( + `Blocked an unstubbed network request to ${url}. ` + + `Tests must not touch the real network — stub it with vi.stubGlobal('fetch', …).` + ) + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..05fcfc6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; + +// Vitest over Jest: it runs TypeScript and ESM natively with no babel or ts-jest +// layer, and it does not contend with Next.js 16's bundler. Nothing here is +// clever — every setting is spelled out rather than inferred, because this repo +// had no test framework before and the config should be readable cold. +export default defineConfig({ + test: { + // The data pipeline is all Node: SQLite, fetch, timers. No DOM, ever. + environment: 'node', + + // Two homes on purpose. Pure-logic tests sit next to the code they cover + // so they move with it; anything needing a database or fixtures lives + // under tests/ where the helpers are. + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + + // Fails any test that reaches for the network without stubbing fetch. A + // suite that quietly hits stats.bungie.net burns API quota and goes flaky. + setupFiles: ['tests/setup/no-network.ts'], + + coverage: { + provider: 'v8', + reporter: ['text'], + // Deliberately no thresholds. Coverage is a diagnostic here, not a gate. + }, + }, + + resolve: { + // Mirrors the `@/*` -> `./src/*` mapping in tsconfig.json. Kept as an + // explicit alias rather than a tsconfig-paths plugin so there is one + // fewer dependency doing something invisible. + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); From 8828da84b5acf5e1a213c01e61e090b12769b4b8 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:44:36 -0400 Subject: [PATCH 05/17] test: add PGCR fixture capture script (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixtures are captured from the live API rather than hand-authored so they encode Bungie's real quirks — absent startingPhaseIndex, entry counts above six, activity durations that disagree with per-player time. A synthetic PGCR would only encode our beliefs about the API, which is what the fixtures exist to check. Every instance ID was selected by querying the local database for runs that actually exhibit the target property, so each case is a real observed run. The non-raid fixture probes forward from a known raid id until isRaidActivityHash rejects one. The script prints the salient fields per capture (entry count, completion count, fromBeginning, duration vs max time played, missing names) so a capture that fails to exhibit its stated property is visible rather than assumed. Run by the maintainer via `npm run capture-fixtures`; the API key is read from .env by the script and never handled directly. --- scripts/capture-pgcr-fixture.ts | 193 ++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 scripts/capture-pgcr-fixture.ts diff --git a/scripts/capture-pgcr-fixture.ts b/scripts/capture-pgcr-fixture.ts new file mode 100644 index 0000000..fa02fb0 --- /dev/null +++ b/scripts/capture-pgcr-fixture.ts @@ -0,0 +1,193 @@ +import 'dotenv/config'; +import fs from 'fs'; +import path from 'path'; +import { BungieEndpoints } from '../src/lib/bungie/endpoints'; +import { isRaidActivityHash } from '../src/lib/bungie/manifest'; +import { readActivityDurationSeconds } from '../src/lib/bungie/pgcr-stats'; +import type { DestinyPostGameCarnageReportData } from '../src/lib/bungie/types'; + +/** + * Captures real PGCR JSON from Bungie into tests/fixtures/. + * + * Fixtures are captured rather than hand-authored so they encode Bungie's actual + * quirks — absent fields, entry counts above six, durations that disagree with + * per-player time. A synthetic PGCR only encodes our beliefs about the API, which + * is exactly what the fixtures exist to check. + * + * PGCR data is public; the captured files are committed as-is. + * + * The instance IDs below were selected by querying the local database for runs + * exhibiting each property, so every case is a real observed run rather than a + * hypothetical. Run with: npm run capture-fixtures + */ + +const FIXTURE_DIR = path.join(process.cwd(), 'tests', 'fixtures'); + +interface Target { + file: string; + instanceId: string; + why: string; +} + +const TARGETS: Target[] = [ + { + file: 'pgcr-fullclear-salvations-edge.json', + instanceId: '17091392013', + why: 'Baseline happy path: six players, started from the beginning, completed.', + }, + { + file: 'pgcr-checkpoint-root-of-nightmares.json', + instanceId: '17091462346', + why: 'Checkpoint run — activityWasStartedFromBeginning is false. Must be excluded from full-clear leaderboards.', + }, + { + file: 'pgcr-zero-completions-vault-of-glass.json', + instanceId: '17091467640', + why: 'No entry has completed = 1. The dominant shape in the table (55% of stored PGCRs).', + }, + { + file: 'pgcr-partial-completion-last-wish.json', + instanceId: '17091283535', + why: 'Some entries completed, some did not. `completed` is per-entry; ANY completion counts.', + }, + { + file: 'pgcr-duration-divergence-garden.json', + instanceId: '17091200569', + why: 'Activity duration (~2069s) far exceeds the longest per-player time (~981s). Tier 1 vs Tier 2 divergence.', + }, + { + file: 'pgcr-no-duration-crotas-end.json', + instanceId: '17091316490', + why: 'Stored with a NULL ended_at, so no usable duration was derivable. Exercises the Tier 3 fallback.', + }, + { + file: 'pgcr-missing-bungie-name.json', + instanceId: '16975643976', + why: 'At least one entry lacks bungieGlobalDisplayName. Player extraction must tolerate it.', + }, +]; + +// Instance IDs are broadly sequential, so a raid's neighbours are almost always +// other activity types. We probe forward from a known raid until isRaidActivityHash +// rejects one, giving a genuine non-raid PGCR without needing a curated id. +// Safely within Number.MAX_SAFE_INTEGER (~9e15), so plain arithmetic is fine here. +const NON_RAID_PROBE_START = 17091392014; +const NON_RAID_PROBE_ATTEMPTS = 12; +const NON_RAID_FILE = 'pgcr-non-raid.json'; + +function requireApiKey(): string { + const key = process.env.BUNGIE_API_KEY; + if (!key) { + console.error('[ERROR] BUNGIE_API_KEY is not set. Add it to .env and re-run.'); + process.exit(1); + } + return key; +} + +async function fetchPGCR( + instanceId: string, + apiKey: string +): Promise { + const response = await fetch(BungieEndpoints.getPGCR(instanceId), { + headers: { 'X-API-Key': apiKey }, + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + console.error(` [ERROR] HTTP ${response.status} for instance ${instanceId}`); + return null; + } + + const body = await response.json(); + if (body.ErrorCode !== 1) { + console.error(` [ERROR] Bungie ErrorCode ${body.ErrorCode} (${body.ErrorStatus}) for ${instanceId}`); + return null; + } + + return body.Response as DestinyPostGameCarnageReportData; +} + +/** Reports the fields each fixture is supposed to demonstrate, so a capture that + * silently fails to exhibit its property is visible rather than assumed. */ +function describe(pgcr: DestinyPostGameCarnageReportData): string { + const hash = pgcr.activityDetails.directorActivityHash || pgcr.activityDetails.referenceId; + const entries = pgcr.entries || []; + const completedCount = entries.filter((e) => e.values?.completed?.basic?.value === 1).length; + const maxTimePlayed = entries.reduce( + (max, e) => Math.max(max, e.values?.timePlayedSeconds?.basic?.value || 0), + 0 + ); + // Uses the production reader so the reported value is the one the writer + // would actually see, not a second interpretation of the same JSON. + const duration = readActivityDurationSeconds(entries); + const missingNames = entries.filter((e) => !e.player?.destinyUserInfo?.bungieGlobalDisplayName).length; + + return [ + `raid=${isRaidActivityHash(hash) ? 'yes' : 'NO '}`, + `hash=${hash}`, + `entries=${entries.length}`, + `completed=${completedCount}`, + `fromBeginning=${pgcr.activityWasStartedFromBeginning}`, + `startingPhaseIndex=${pgcr.startingPhaseIndex}`, + `durationSec=${duration ?? 'ABSENT'}`.padEnd(20), + `maxTimePlayed=${maxTimePlayed}`, + `missingNames=${missingNames}`, + ].join(' '); +} + +async function capture(target: Target, apiKey: string): Promise { + console.log(`\n${target.file} (instance ${target.instanceId})`); + const pgcr = await fetchPGCR(target.instanceId, apiKey); + if (!pgcr) return false; + + fs.writeFileSync(path.join(FIXTURE_DIR, target.file), JSON.stringify(pgcr, null, 2)); + console.log(` ${describe(pgcr)}`); + return true; +} + +async function captureNonRaid(apiKey: string): Promise { + console.log(`\n${NON_RAID_FILE} (probing forward from ${NON_RAID_PROBE_START})`); + + for (let i = 0; i < NON_RAID_PROBE_ATTEMPTS; i++) { + const instanceId = String(NON_RAID_PROBE_START + i); + const pgcr = await fetchPGCR(instanceId, apiKey); + if (!pgcr) continue; + + const hash = pgcr.activityDetails.directorActivityHash || pgcr.activityDetails.referenceId; + if (isRaidActivityHash(hash)) { + console.log(` ${instanceId} is a raid — probing next`); + continue; + } + + fs.writeFileSync(path.join(FIXTURE_DIR, NON_RAID_FILE), JSON.stringify(pgcr, null, 2)); + console.log(` captured instance ${instanceId}`); + console.log(` ${describe(pgcr)}`); + return true; + } + + console.error(` [ERROR] No non-raid activity found in ${NON_RAID_PROBE_ATTEMPTS} attempts.`); + return false; +} + +async function main() { + const apiKey = requireApiKey(); + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); + + console.log('Capturing PGCR fixtures from Bungie into tests/fixtures/'); + console.log('Check the reported fields below against each fixture\'s stated purpose.'); + + let ok = 0; + for (const target of TARGETS) { + if (await capture(target, apiKey)) ok++; + } + if (await captureNonRaid(apiKey)) ok++; + + const total = TARGETS.length + 1; + console.log(`\n${ok}/${total} fixtures captured.`); + if (ok < total) { + console.log('Some captures failed — the missing cases will need a synthetic fixture instead.'); + process.exit(1); + } +} + +main(); From 2fc321f2ce4fa840c6c97c8aa608f388393ec630 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:11:01 -0400 Subject: [PATCH 06/17] test: add database and PGCR test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test databases are real SQLite files in a per-file temp directory, not `:memory:`. SQLite silently downgrades journal_mode=WAL to 'memory' for in-memory databases, so `:memory:` would exercise different journal semantics than production and undercut the reason for using a real database at all. Verified, not assumed. See ADR 0003. The path is set in a setupFile because DB_PATH is a module-level const resolved at import time; setting it there rather than inside a helper means test files can use ordinary static imports. It also relocates DATA_DIR, isolating maintenance-state.json — getDb() reads that file on every single call and would throw DatabaseMaintenanceError across the whole suite if the real database were mid-vacuum. Seeding goes through insertFullPGCR rather than raw INSERTs, because that is where ended_at is derived and last_seen_at advanced. Raw inserts would produce rows production could never create. Fixtures for realism, builders for permutation. --- tests/helpers/db.ts | 35 +++++++ tests/helpers/pgcr-builder.ts | 169 ++++++++++++++++++++++++++++++++++ tests/helpers/seed.ts | 122 ++++++++++++++++++++++++ tests/setup/test-db-path.ts | 35 +++++++ vitest.config.ts | 8 +- 5 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 tests/helpers/db.ts create mode 100644 tests/helpers/pgcr-builder.ts create mode 100644 tests/helpers/seed.ts create mode 100644 tests/setup/test-db-path.ts diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 0000000..5af300e --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,35 @@ +import type Database from 'better-sqlite3'; +import { getDb } from '@/lib/db'; + +/** + * Access to the per-file test database. + * + * The database path is set by tests/setup/test-db-path.ts before this file's + * imports run, so `getDb()` here opens a throwaway database in a temp directory + * with the production schema already applied — initializeSchema() runs inside + * getDb(), including the ended_at migration guard and the Phase 3 indexes. The + * schema under test is therefore the production schema by construction rather + * than a duplicated copy that can drift. + */ + +export function testDb(): Database.Database { + return getDb(); +} + +/** + * Empties every table, leaving the schema intact. Call in beforeEach so tests + * share one connection but never share rows — reopening the database per test + * would re-run schema init for no benefit. + * + * Order respects the pgcr_players -> pgcrs foreign key. + */ +export function resetTestDb(): void { + const db = getDb(); + db.exec(` + DELETE FROM pgcr_players; + DELETE FROM pgcrs; + DELETE FROM players; + DELETE FROM active_sessions; + DELETE FROM crawler_state; + `); +} diff --git a/tests/helpers/pgcr-builder.ts b/tests/helpers/pgcr-builder.ts new file mode 100644 index 0000000..63c4f74 --- /dev/null +++ b/tests/helpers/pgcr-builder.ts @@ -0,0 +1,169 @@ +import type { + DestinyHistoricalStatsValue, + DestinyPostGameCarnageReportData, + DestinyPostGameCarnageReportEntry, +} from '@/lib/bungie/types'; + +/** + * Programmatic PGCR builders. + * + * Fixtures for realism, builders for permutation. When a test needs one specific + * field varied — a duration removed, a single entry's completion flipped — reach + * for a builder. When a test needs to prove we handle what Bungie actually sends, + * reach for a fixture in ../fixtures. + * + * Defaults describe a clean six-player full clear, so every builder call states + * only what makes its case interesting. + */ + +/** Salvation's Edge. Any hash in manifest.ts's RAID_DEFINITIONS works. */ +export const RAID_HASH = 2192826039; + +/** Not present in RAID_DEFINITIONS, so isRaidActivityHash rejects it. */ +export const NON_RAID_HASH = 1; + +function stat(value: number): DestinyHistoricalStatsValue { + return { basic: { value, displayValue: String(value) } }; +} + +export interface EntryOptions { + membershipId?: string; + membershipType?: number; + displayName?: string; + /** Pass `null` to omit the field entirely — the case where Bungie withholds it. */ + bungieGlobalDisplayName?: string | null; + bungieGlobalDisplayNameCode?: number | null; + characterClass?: string; + lightLevel?: number; + completed?: boolean; + kills?: number; + deaths?: number; + assists?: number; + timePlayedSeconds?: number; + /** Per-player join offset. Pass `null` to omit, collapsing Tier 2 to MAX(timePlayed). */ + startSeconds?: number | null; + /** Activity-level duration. Pass `null` to omit, forcing the Tier 2 fallback. */ + activityDurationSeconds?: number | null; +} + +export function buildEntry(options: EntryOptions = {}): DestinyPostGameCarnageReportEntry { + const { + membershipId = '4611686018400000001', + membershipType = 3, + displayName = 'Guardian', + bungieGlobalDisplayName = 'Guardian', + bungieGlobalDisplayNameCode = 1234, + characterClass = 'Warlock', + lightLevel = 2010, + completed = true, + kills = 100, + deaths = 2, + assists = 40, + timePlayedSeconds = 1800, + startSeconds = 0, + activityDurationSeconds = 1800, + } = options; + + const values: Record = { + completed: stat(completed ? 1 : 0), + kills: stat(kills), + deaths: stat(deaths), + assists: stat(assists), + timePlayedSeconds: stat(timePlayedSeconds), + }; + + // Omitted rather than zeroed: the readers distinguish absent from 0, and an + // absent activityDurationSeconds is what drives the Tier 2 fallback. + if (startSeconds !== null) { + values.startSeconds = stat(startSeconds); + } + if (activityDurationSeconds !== null) { + values.activityDurationSeconds = stat(activityDurationSeconds); + } + + return { + standing: 0, + player: { + destinyUserInfo: { + membershipId, + membershipType, + displayName, + ...(bungieGlobalDisplayName !== null ? { bungieGlobalDisplayName } : {}), + ...(bungieGlobalDisplayNameCode !== null ? { bungieGlobalDisplayNameCode } : {}), + }, + characterClass, + characterLevel: 50, + lightLevel, + }, + values, + }; +} + +export interface PGCROptions { + instanceId?: string; + activityHash?: number; + /** Set independently of directorActivityHash to test the referenceId fallback. */ + referenceId?: number; + /** ISO 8601. Bungie reports UTC. */ + period?: string; + activityWasStartedFromBeginning?: boolean; + /** + * Bungie no longer sends this — it is absent on all 827k stored rows. Pass + * `null` to reproduce reality; pass a number only when testing the legacy path. + */ + startingPhaseIndex?: number | null; + entries?: DestinyPostGameCarnageReportEntry[]; +} + +export function buildPGCR(options: PGCROptions = {}): DestinyPostGameCarnageReportData { + const { + instanceId = '17091392013', + activityHash = RAID_HASH, + referenceId = activityHash, + period = '2026-07-26T12:00:00Z', + activityWasStartedFromBeginning = true, + startingPhaseIndex = null, + entries = buildFireteam(), + } = options; + + const pgcr = { + period, + activityWasStartedFromBeginning, + activityDetails: { + referenceId, + directorActivityHash: activityHash, + instanceId, + mode: 4, + modes: [4], + }, + entries, + } as DestinyPostGameCarnageReportData; + + if (startingPhaseIndex !== null) { + pgcr.startingPhaseIndex = startingPhaseIndex; + } + + return pgcr; +} + +/** + * Six distinct players. `completions` sets how many finished, counting from the + * first entry — so `buildFireteam({ completions: 1 })` is the "only one of six + * completed" case. + */ +export function buildFireteam( + options: { size?: number; completions?: number; entry?: EntryOptions } = {} +): DestinyPostGameCarnageReportEntry[] { + const { size = 6, completions = size, entry = {} } = options; + + return Array.from({ length: size }, (_, index) => + buildEntry({ + membershipId: `461168601840000000${index + 1}`, + displayName: `Guardian${index + 1}`, + bungieGlobalDisplayName: `Guardian${index + 1}`, + bungieGlobalDisplayNameCode: 1000 + index, + completed: index < completions, + ...entry, + }) + ); +} diff --git a/tests/helpers/seed.ts b/tests/helpers/seed.ts new file mode 100644 index 0000000..670a60c --- /dev/null +++ b/tests/helpers/seed.ts @@ -0,0 +1,122 @@ +import { getDb } from '@/lib/db'; +import { insertFullPGCR, upsertPlayer } from '@/lib/db/queries'; +import { getRaidKeyFromHash } from '@/lib/bungie/manifest'; +import { RAID_HASH } from './pgcr-builder'; + +/** + * Seeds runs through the real ingestion chokepoint. + * + * All four production ingestion sources funnel through `insertFullPGCR`, which + * is where `ended_at` is derived and where `players.last_seen_at` is advanced. + * Seeding with raw INSERTs would skip both and quietly produce rows that could + * never exist in production — so tests would pass against data the app can't + * create. Everything here goes through the same function the crawler calls. + */ + +const HOUR = 3600; + +export interface SeedRunOptions { + instanceId: string; + /** Unix seconds the activity started. */ + period?: number; + /** Members who finished. Each becomes a completed pgcr_players row. */ + completedBy?: string[]; + /** Members present who did not finish. */ + incompleteBy?: string[]; + activityHash?: number; + raidKey?: string; + /** False marks a checkpoint run, which every leaderboard excludes. */ + startedFromBeginning?: boolean; + /** Overrides the run-level completed flag; defaults to "anyone completed". */ + completed?: boolean; + /** Tier 1 duration. Pass null to force the Tier 2 fallback from time played. */ + activityDurationSeconds?: number | null; + timePlayedSeconds?: number; + startSeconds?: number | null; +} + +/** Unix seconds, `hours` in the past. Runs are seeded relative to now because + * every leaderboard query filters on a cutoff derived from Date.now(). */ +export function hoursAgo(hours: number): number { + return Math.floor(Date.now() / 1000) - Math.round(hours * HOUR); +} + +export function seedRun(options: SeedRunOptions): void { + const { + instanceId, + period = hoursAgo(2), + completedBy = [], + incompleteBy = [], + activityHash = RAID_HASH, + raidKey = getRaidKeyFromHash(activityHash), + startedFromBeginning = true, + completed = completedBy.length > 0, + activityDurationSeconds = 1800, + timePlayedSeconds = 1800, + startSeconds = 0, + } = options; + + const members = [ + ...completedBy.map((membershipId) => ({ membershipId, completed: true })), + ...incompleteBy.map((membershipId) => ({ membershipId, completed: false })), + ]; + + insertFullPGCR( + { + instanceId, + activityHash, + raidKey, + period, + // Always 0: Bungie no longer sends startingPhaseIndex, and the writer + // coerces it with `|| 0` anyway. Checkpoint runs are expressed through + // startedFromBeginning, which is what the leaderboards actually filter on. + startingPhaseIndex: 0, + activityWasStartedFromBeginning: startedFromBeginning, + completed, + playerCount: members.length, + source: 'test', + activityDurationSeconds, + }, + members.map((member) => ({ + instanceId, + membershipId: member.membershipId, + membershipType: 3, + displayName: `Guardian-${member.membershipId}`, + bungieGlobalDisplayName: `Guardian-${member.membershipId}`, + characterClass: 'Warlock', + lightLevel: 2010, + completed: member.completed, + kills: 100, + deaths: 2, + assists: 40, + timePlayedSeconds, + startSeconds, + })) + ); +} + +/** + * Registers a player in the `players` table so the leaderboard's LEFT JOIN finds + * a name. Seeding a run alone does not do this — in production a player is only + * added once crawled — which is exactly why the join is a LEFT one. + */ +export function seedPlayer( + membershipId: string, + bungieGlobalDisplayName?: string, + bungieGlobalDisplayNameCode?: number +): void { + upsertPlayer({ + membershipId, + membershipType: 3, + displayName: bungieGlobalDisplayName ?? `Guardian-${membershipId}`, + bungieGlobalDisplayName, + bungieGlobalDisplayNameCode, + }); +} + +/** Raw row read, for asserting what the writer actually persisted. */ +export function readPgcrRow(instanceId: string): Record | undefined { + return getDb() + .prepare('SELECT * FROM pgcrs WHERE instance_id = ?') + .get(instanceId) as Record | undefined; +} diff --git a/tests/setup/test-db-path.ts b/tests/setup/test-db-path.ts new file mode 100644 index 0000000..16ea453 --- /dev/null +++ b/tests/setup/test-db-path.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll } from 'vitest'; + +/** + * Points every test file at its own throwaway database directory. + * + * This runs as a setupFile, which Vitest executes *before* the test file's own + * imports. That ordering is the whole point: `DB_PATH` in src/lib/db/index.ts is + * a module-level const evaluated at import time, so if a test file statically + * imported anything that pulls in the db module before this ran, the path would + * resolve against the real data directory. Setting it here means test files can + * use ordinary static imports instead of `await import()` everywhere. + * + * Setting RAID_TRACKER_DB_PATH also relocates DATA_DIR, which derives from its + * dirname (maintenance/state.ts). That isolates maintenance-state.json too — + * necessary because getDb() calls isDbQuiesceActive() on every invocation and + * would otherwise throw DatabaseMaintenanceError for the whole suite if the real + * database happened to be mid-vacuum. + * + * A temp file rather than `:memory:` because SQLite silently downgrades WAL for + * in-memory databases. See docs/adr/0003. + */ + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dff-test-')); + +process.env.RAID_TRACKER_DB_PATH = path.join(dir, 'test.db'); + +// Keep the suite off any real key even if a test reaches code that reads one. +process.env.BUNGIE_API_KEY = 'test-key-not-a-real-credential'; + +afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 05fcfc6..cc9142c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,9 +15,11 @@ export default defineConfig({ // under tests/ where the helpers are. include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], - // Fails any test that reaches for the network without stubbing fetch. A - // suite that quietly hits stats.bungie.net burns API quota and goes flaky. - setupFiles: ['tests/setup/no-network.ts'], + // Order matters. test-db-path must run before anything imports the db + // module, because DB_PATH is resolved at import time — see the file's + // comment. no-network fails any test that reaches the real internet + // without stubbing fetch, which would burn API quota and go flaky. + setupFiles: ['tests/setup/test-db-path.ts', 'tests/setup/no-network.ts'], coverage: { provider: 'v8', From b1e488f4ce2cc8246c691d18f96b65367278c73c Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:11:01 -0400 Subject: [PATCH 07/17] test: cover the leaderboard query (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Against a real database, not a mock — better-sqlite3 opens a fresh one in about a millisecond, and mocking would validate the scaffolding instead of the SQL. Covers membership (checkpoint runs, zero-completion runs, non-finishers, and runs with no derivable end time are all excluded), the cutoff boundary judged on end time rather than start, raid filtering, the three-key tie-break, competition-style rank assignment, and the display name path. Two notes on the brief this came from: fullClearsOnly has no false branch to test — it is forced true on every path — and the SQL lives in lib/cache/leaderboard-cache.ts, not lib/db/queries.ts. One test pins a defect rather than asserting correctness: formatDisplayName drops the #Code when the code is 0, because it guards with a truthiness check. Reported, not fixed. --- tests/db/leaderboard.test.ts | 237 +++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 tests/db/leaderboard.test.ts diff --git a/tests/db/leaderboard.test.ts b/tests/db/leaderboard.test.ts new file mode 100644 index 0000000..c3a6aa1 --- /dev/null +++ b/tests/db/leaderboard.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { runLeaderboardRows } from '@/lib/cache/leaderboard-cache'; +import { resetTestDb } from '../helpers/db'; +import { seedPlayer, seedRun } from '../helpers/seed'; + +/** + * The leaderboard is the product. A crash here would be noticed within minutes; + * a silently wrong row set would not be noticed at all, which is why these tests + * assert membership and ordering rather than "it returned something". + * + * Raid hashes are real values from RAID_DEFINITIONS in bungie/manifest.ts. + */ +const SALVATIONS_EDGE = 2192826039; +const CROTAS_END = 1566480315; + +const DURATION = 1800; +const HOURS_BACK = 24; + +/** Places a run so it ends exactly `offsetSeconds` relative to the 24h cutoff. + * Negative lands inside the window, positive lands outside (further in the past). */ +function endingRelativeToCutoff(offsetSeconds: number): number { + const cutoff = Math.floor(Date.now() / 1000) - HOURS_BACK * 3600; + return cutoff - offsetSeconds - DURATION; +} + +beforeEach(() => { + resetTestDb(); +}); + +describe('who appears on the leaderboard', () => { + it('counts a raid instance once per player, however many entries they have', () => { + // pgcr_players is keyed (instance_id, membership_id), so a player running + // two characters through one instance collapses to a single row at insert + // and COUNT(DISTINCT instance_id) keeps it that way if that ever changes. + seedRun({ instanceId: '1', completedBy: ['p1'] }); + seedRun({ instanceId: '2', completedBy: ['p1'] }); + + const rows = runLeaderboardRows(HOURS_BACK, [], 10); + + expect(rows).toHaveLength(1); + expect(rows[0].completions).toBe(2); + }); + + it('excludes a checkpoint run', () => { + // The live full-clear signal. Note this is Bungie's raw + // activityWasStartedFromBeginning, not ProcessedPGCR.isFullClear — that + // field is computed, never persisted, and would report every run as a + // full clear. See docs/testing-framework-plan.md. + seedRun({ instanceId: '1', completedBy: ['p1'], startedFromBeginning: false }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)).toEqual([]); + }); + + it('excludes a player who was present but did not finish', () => { + seedRun({ instanceId: '1', completedBy: ['p1'], incompleteBy: ['p2'] }); + + const rows = runLeaderboardRows(HOURS_BACK, [], 10); + + expect(rows.map((r) => r.membershipId)).toEqual(['p1']); + }); + + it('excludes a run that nobody completed', () => { + // 55% of stored PGCRs have zero completions, so this is the single most + // common shape in the table rather than an edge case. + seedRun({ instanceId: '1', incompleteBy: ['p1', 'p2'] }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)).toEqual([]); + }); + + it('excludes a run with no derivable end time', () => { + // ended_at IS NULL fails `ended_at >= cutoff`, so the run cannot be placed + // in any time window and drops out entirely. + seedRun({ + instanceId: '1', + completedBy: ['p1'], + activityDurationSeconds: null, + timePlayedSeconds: 0, + }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)).toEqual([]); + }); +}); + +describe('the time window', () => { + it('includes a run that ended just inside the cutoff', () => { + seedRun({ + instanceId: '1', + period: endingRelativeToCutoff(-60), + completedBy: ['p1'], + activityDurationSeconds: DURATION, + }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)).toHaveLength(1); + }); + + it('excludes a run that ended just outside the cutoff', () => { + seedRun({ + instanceId: '1', + period: endingRelativeToCutoff(60), + completedBy: ['p1'], + activityDurationSeconds: DURATION, + }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)).toEqual([]); + }); + + it('judges the window by when a run ended, not when it started', () => { + // A long run that began before the cutoff but finished inside it counts. + // This is precisely what the ended_at denormalization exists to express. + const cutoff = Math.floor(Date.now() / 1000) - HOURS_BACK * 3600; + seedRun({ + instanceId: '1', + period: cutoff - 600, + completedBy: ['p1'], + activityDurationSeconds: 1200, + }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)).toHaveLength(1); + }); +}); + +describe('raid filtering', () => { + it('counts only the requested raid', () => { + seedRun({ instanceId: '1', completedBy: ['p1'], activityHash: SALVATIONS_EDGE }); + seedRun({ instanceId: '2', completedBy: ['p1'], activityHash: CROTAS_END }); + + const rows = runLeaderboardRows(HOURS_BACK, ['salvations_edge'], 10); + + expect(rows[0].completions).toBe(1); + }); + + it('counts every raid when no filter is given', () => { + seedRun({ instanceId: '1', completedBy: ['p1'], activityHash: SALVATIONS_EDGE }); + seedRun({ instanceId: '2', completedBy: ['p1'], activityHash: CROTAS_END }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)[0].completions).toBe(2); + }); + + it('counts the union when several raids are requested', () => { + seedRun({ instanceId: '1', completedBy: ['p1'], activityHash: SALVATIONS_EDGE }); + seedRun({ instanceId: '2', completedBy: ['p1'], activityHash: CROTAS_END }); + + const rows = runLeaderboardRows(HOURS_BACK, ['salvations_edge', 'crotas_end'], 10); + + expect(rows[0].completions).toBe(2); + }); +}); + +describe('ordering and limit', () => { + it('ranks the most completions first', () => { + seedRun({ instanceId: '1', completedBy: ['p1', 'p2'] }); + seedRun({ instanceId: '2', completedBy: ['p1'] }); + + const rows = runLeaderboardRows(HOURS_BACK, [], 10); + + expect(rows.map((r) => r.membershipId)).toEqual(['p1', 'p2']); + }); + + it('breaks a tie in favour of whoever got there first', () => { + // lastClearAt ASC. A player who reached three clears an hour ago outranks + // one who reached three a minute ago, so a stale PGCR discovered late still + // slots its player at their true historical position. + seedRun({ instanceId: '1', period: hoursBeforeNow(10), completedBy: ['early'] }); + seedRun({ instanceId: '2', period: hoursBeforeNow(1), completedBy: ['late'] }); + + const rows = runLeaderboardRows(HOURS_BACK, [], 10); + + expect(rows.map((r) => r.membershipId)).toEqual(['early', 'late']); + }); + + it('breaks a remaining tie by membership id so the order is never arbitrary', () => { + const period = hoursBeforeNow(5); + seedRun({ instanceId: '1', period, completedBy: ['bbb', 'aaa'] }); + + const rows = runLeaderboardRows(HOURS_BACK, [], 10); + + expect(rows.map((r) => r.membershipId)).toEqual(['aaa', 'bbb']); + }); + + it('returns at most the requested number of players', () => { + seedRun({ instanceId: '1', completedBy: ['p1', 'p2', 'p3'] }); + + expect(runLeaderboardRows(HOURS_BACK, [], 2)).toHaveLength(2); + }); +}); + +describe('rank assignment', () => { + it('gives tied players the same rank and skips the ranks they consumed', () => { + // Competition ranking: 1, 2, 2, 4 — not 1, 2, 2, 3. + seedRun({ instanceId: '1', period: hoursBeforeNow(5), completedBy: ['top', 'mid1', 'mid2', 'low'] }); + seedRun({ instanceId: '2', period: hoursBeforeNow(5), completedBy: ['top', 'mid1', 'mid2'] }); + seedRun({ instanceId: '3', period: hoursBeforeNow(5), completedBy: ['top'] }); + + const rows = runLeaderboardRows(HOURS_BACK, [], 10); + + expect(rows.map((r) => [r.membershipId, r.completions, r.rank])).toEqual([ + ['top', 3, 1], + ['mid1', 2, 2], + ['mid2', 2, 2], + ['low', 1, 4], + ]); + }); +}); + +describe('display names', () => { + it('renders the full Name#Code, zero-padding the code to four digits', () => { + seedPlayer('p1', 'Guardian', 42); + seedRun({ instanceId: '1', completedBy: ['p1'] }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)[0].displayName).toBe('Guardian#0042'); + }); + + it('falls back to the name recorded on the run when the player is not yet crawled', () => { + // A player appears in pgcr_players the moment a run is ingested, but only + // enters the players table once crawled — which is why the join is a LEFT + // one and why this fallback is load-bearing rather than defensive. + seedRun({ instanceId: '1', completedBy: ['p1'] }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)[0].displayName).toBe('Guardian-p1'); + }); + + it('BUG: drops the #Code entirely when the code is zero', () => { + // formatDisplayName guards with `&& entry.bungieGlobalDisplayNameCode`, so a + // code of 0 is falsy and the branch is skipped, yielding a partial name. + // CLAUDE.md calls the full Name#Code form load-bearing and notes partial + // names were a real bug before. Pinned as current behaviour, not endorsed — + // see docs/decisions.md. + seedPlayer('p1', 'Guardian', 0); + seedRun({ instanceId: '1', completedBy: ['p1'] }); + + expect(runLeaderboardRows(HOURS_BACK, [], 10)[0].displayName).toBe('Guardian'); + }); +}); + +function hoursBeforeNow(hours: number): number { + return Math.floor(Date.now() / 1000) - Math.round(hours * 3600); +} From 6bc0c4301c078f8a1973af73918a88faf2b011dc Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:11:01 -0400 Subject: [PATCH 08/17] test: cover ended_at derivation (phase 5, retargeted) The brief asked for parity tests guarding an in-flight cutover from the run_durations CTE to pgcrs.ended_at. That cutover already shipped in 610408e and no run_durations SQL remains in src/, so there is nothing left to guard. Retargeted to the code that replaced it: the three-tier duration fallback in computeActivityDurationSeconds and the future-end-time corruption guard, asserted both directly and through insertFullPGCR. Covers Tier 1 winning over per-player time, a zero duration being treated as unusable, late-joiner offsets, non-completers counting toward duration (the CTE excluded them), the Tier 2 collapse when start offsets are absent, Tier 3 yielding NULL, the future-end-time discard with its clock-skew headroom, and last_seen_at advancing monotonically. --- tests/db/ended-at-derivation.test.ts | 191 +++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 tests/db/ended-at-derivation.test.ts diff --git a/tests/db/ended-at-derivation.test.ts b/tests/db/ended-at-derivation.test.ts new file mode 100644 index 0000000..87f7f66 --- /dev/null +++ b/tests/db/ended-at-derivation.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { FUTURE_ENDED_SKEW_SECONDS, computeActivityDurationSeconds } from '@/lib/db/queries'; +import { getDb } from '@/lib/db'; +import { resetTestDb } from '../helpers/db'; +import { hoursAgo, readPgcrRow, seedPlayer, seedRun } from '../helpers/seed'; + +/** + * `pgcrs.ended_at` is the denormalized run end time (period + duration) that + * replaced the old `run_durations` CTE in the phase 3b reader cutover (610408e). + * Every leaderboard, the recent-completions list, and the completion-time stats + * now filter and sort on it, so a wrong derivation does not crash anything — it + * silently returns the wrong set of runs. That is the failure mode this file + * exists to catch. + */ + +beforeEach(() => { + resetTestDb(); +}); + +describe('computeActivityDurationSeconds', () => { + it('prefers Bungie\'s activity-level duration over per-player time', () => { + const players = [{ startSeconds: 0, timePlayedSeconds: 900 }]; + + expect(computeActivityDurationSeconds(2400, players)).toBe(2400); + }); + + it('falls back to per-player time when the activity duration is absent', () => { + const players = [ + { startSeconds: 0, timePlayedSeconds: 900 }, + { startSeconds: 0, timePlayedSeconds: 1500 }, + ]; + + expect(computeActivityDurationSeconds(null, players)).toBe(1500); + }); + + it('treats a zero activity duration as unusable rather than as a real value', () => { + // `> 0` not `!= null`: a zero duration is Bungie reporting nothing useful, + // and accepting it would pin ended_at to the run's start time. + const players = [{ startSeconds: 0, timePlayedSeconds: 1500 }]; + + expect(computeActivityDurationSeconds(0, players)).toBe(1500); + }); + + it('counts a late joiner\'s offset so the run is not measured from their arrival', () => { + // The player who joined 1200s in and played 600s establishes a 1800s run, + // even though nobody's individual time exceeds 900s. + const players = [ + { startSeconds: 0, timePlayedSeconds: 900 }, + { startSeconds: 1200, timePlayedSeconds: 600 }, + ]; + + expect(computeActivityDurationSeconds(null, players)).toBe(1800); + }); + + it('considers players who did not complete, not just those who did', () => { + // The pre-cutover CTE filtered on completed = 1. This deliberately does not: + // someone who left before the end still bounds how long the activity ran. + const players = [ + { startSeconds: 0, timePlayedSeconds: 2400 }, + { startSeconds: 0, timePlayedSeconds: 600 }, + ]; + + expect(computeActivityDurationSeconds(null, players)).toBe(2400); + }); + + it('collapses to the longest time played when no start offsets are reported', () => { + const players = [ + { startSeconds: null, timePlayedSeconds: 900 }, + { timePlayedSeconds: 1500 }, + ]; + + expect(computeActivityDurationSeconds(null, players)).toBe(1500); + }); + + it('reports no duration at all for an empty PGCR', () => { + expect(computeActivityDurationSeconds(null, [])).toBeNull(); + }); + + it('reports no duration when every player has zero time played', () => { + const players = [ + { startSeconds: 0, timePlayedSeconds: 0 }, + { startSeconds: 0, timePlayedSeconds: 0 }, + ]; + + expect(computeActivityDurationSeconds(null, players)).toBeNull(); + }); +}); + +describe('ended_at as persisted by insertFullPGCR', () => { + it('stores the run end as start plus duration', () => { + const period = hoursAgo(3); + seedRun({ instanceId: '1', period, completedBy: ['p1'], activityDurationSeconds: 1800 }); + + expect(readPgcrRow('1')?.ended_at).toBe(period + 1800); + }); + + it('leaves the end time unknown when no duration can be derived', () => { + // Tier 3. A NULL ended_at drops the run from every leaderboard, because + // they all filter `ended_at >= cutoff`. That exclusion is intended: a run + // with no derivable end time cannot be placed in a time window. + seedRun({ + instanceId: '1', + completedBy: ['p1'], + activityDurationSeconds: null, + timePlayedSeconds: 0, + }); + + expect(readPgcrRow('1')?.ended_at).toBeNull(); + }); + + it('discards a future end time as corrupt', () => { + // Bungie reports absurd durations for farm/checkpoint megalobby instances + // — multi-day "activities". A PGCR is only ingested after the run ended, + // so an end time beyond now is malformed by definition. + seedRun({ + instanceId: '1', + period: hoursAgo(1), + completedBy: ['p1'], + activityDurationSeconds: 30 * 24 * 3600, + }); + + expect(readPgcrRow('1')?.ended_at).toBeNull(); + }); + + it('keeps a just-finished run whose end time is barely ahead of the ingest clock', () => { + // Clock skew between Bungie and the crawler must not be mistaken for + // corruption, so the guard allows an hour of headroom. + const period = Math.floor(Date.now() / 1000); + const duration = FUTURE_ENDED_SKEW_SECONDS - 60; + seedRun({ instanceId: '1', period, completedBy: ['p1'], activityDurationSeconds: duration }); + + expect(readPgcrRow('1')?.ended_at).toBe(period + duration); + }); +}); + +describe('players.last_seen_at maintenance', () => { + it('advances to the end time of a newly ingested run', () => { + const period = hoursAgo(2); + seedPlayer('p1'); + seedRun({ instanceId: '1', period, completedBy: ['p1'], activityDurationSeconds: 1800 }); + + expect(readLastSeen('p1')).toBe(period + 1800); + }); + + it('never moves backwards when an older run is ingested late', () => { + // The scanner backfills runs out of order, so an old PGCR routinely arrives + // after a newer one. Moving last_seen_at backwards would demote an active + // player into the cold crawl bucket. + const recent = hoursAgo(1); + seedPlayer('p1'); + seedRun({ instanceId: '1', period: recent, completedBy: ['p1'], activityDurationSeconds: 1800 }); + seedRun({ instanceId: '2', period: hoursAgo(50), completedBy: ['p1'], activityDurationSeconds: 1800 }); + + expect(readLastSeen('p1')).toBe(recent + 1800); + }); + + it('is untouched by a run with no derivable end time', () => { + seedPlayer('p1'); + seedRun({ + instanceId: '1', + completedBy: ['p1'], + activityDurationSeconds: null, + timePlayedSeconds: 0, + }); + + expect(readLastSeen('p1')).toBeFalsy(); + }); + + it('advances for a player who did not complete the run', () => { + // last_seen_at tracks presence, not achievement — someone who joined and + // left was still online, and the crawl buckets care about that. + const period = hoursAgo(2); + seedPlayer('p2'); + seedRun({ + instanceId: '1', + period, + completedBy: ['p1'], + incompleteBy: ['p2'], + activityDurationSeconds: 1800, + }); + + expect(readLastSeen('p2')).toBe(period + 1800); + }); +}); + +function readLastSeen(membershipId: string): number | null { + const row = getDb() + .prepare('SELECT last_seen_at FROM players WHERE membership_id = ?') + .get(membershipId) as { last_seen_at: number | null } | undefined; + return row?.last_seen_at ?? null; +} From 820996018e2a7e445c5c30dc261ee67185df8049 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:14:08 -0400 Subject: [PATCH 09/17] test: cover Bungie error handling and rate limiting (phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocked at the fetch boundary only. Stubbing our own modules would test the stubs; stubbing fetch tests the response handling that decides whether a raid gets ingested or dropped. Correction to the brief: request() has no retry logic. It classifies a failure and, when Bungie signals throttling, pauses the shared per-key rate limiter — the throttle applies to the key, not to the one request that saw it. The tests follow that actual behaviour. - maintenance: the SystemDisabled predicate, with its negative cases (privacy restriction, plain 5xx, timeout, error-shaped impostor) carrying as much weight as the positive one. A false positive pauses the whole crawler for a blip; a false negative burns thousands of doomed requests. - client: payload handling, the typed-vs-untyped error distinction that the maintenance predicate depends on, non-JSON Cloudflare bodies, and each of the four pause paths (ThrottleSeconds, 429 with and without Retry-After, and the 1672 game-server backoff that reports no duration). - rate-limiter: FIFO serialization, and pauses that catch queued waiters, extend a sleep already in progress, and never shorten an existing pause. Timing is asserted with fake timers, so the suite stays fast. --- src/lib/bungie/client.test.ts | 213 +++++++++++++++++++++++++++++ src/lib/bungie/maintenance.test.ts | 55 ++++++++ src/lib/utils/rate-limiter.test.ts | 160 ++++++++++++++++++++++ 3 files changed, 428 insertions(+) create mode 100644 src/lib/bungie/client.test.ts create mode 100644 src/lib/bungie/maintenance.test.ts create mode 100644 src/lib/utils/rate-limiter.test.ts diff --git a/src/lib/bungie/client.test.ts b/src/lib/bungie/client.test.ts new file mode 100644 index 0000000..6a7a135 --- /dev/null +++ b/src/lib/bungie/client.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BungieAPIError, BungieClient } from './client'; + +/** + * Mocked at the fetch boundary only — never at our own module boundaries. + * Stubbing `getPGCR` would test the stub; stubbing `fetch` tests the response + * handling that actually decides whether a raid gets ingested or dropped. + * + * `request()` does no retrying. What it does is classify a failure and, when + * Bungie signals throttling, pause the shared per-key rate limiter — because the + * throttle applies to the key, not to the one request that happened to see it. + * These tests cover that classification and that dispatch. + */ + +const PGCR_BODY = { + Response: { activityDetails: { instanceId: '123' } }, + ErrorCode: 1, + ErrorStatus: 'Success', + Message: 'Ok', + ThrottleSeconds: 0, +}; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }); +} + +/** High RPS so ordinary spacing never interferes with what a test is asserting. */ +function makeClient(): BungieClient { + return new BungieClient('test-api-key', 1000); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('successful responses', () => { + it('returns the parsed payload', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(PGCR_BODY))); + + const result = await makeClient().getPGCR('123'); + + expect(result.Response.activityDetails.instanceId).toBe('123'); + }); + + it('authenticates with the API key header', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(PGCR_BODY)); + vi.stubGlobal('fetch', fetchMock); + + await makeClient().getPGCR('123'); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers['X-API-Key']).toBe('test-api-key'); + }); + + it('requests the PGCR from the stats host', async () => { + // PGCRs live on stats.bungie.net, not www.bungie.net. Getting this wrong + // fails every ingestion path at once. + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(PGCR_BODY)); + vi.stubGlobal('fetch', fetchMock); + + await makeClient().getPGCR('123'); + + expect(String(fetchMock.mock.calls[0][0])).toContain( + 'stats.bungie.net/Platform/Destiny2/Stats/PostGameCarnageReport/123/' + ); + }); +}); + +describe('Bungie-level errors', () => { + it('raises a typed error carrying Bungie\'s own code and status', async () => { + // The type matters: isBungieSystemDisabledError is instanceof-based, so a + // generic Error here would stop the crawler ever pausing for maintenance. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + Response: null, + ErrorCode: 5, + ErrorStatus: 'SystemDisabled', + Message: 'This system is temporarily disabled.', + ThrottleSeconds: 0, + }) + ) + ); + + const error = await makeClient().getPGCR('123').catch((e) => e); + + expect(error).toBeInstanceOf(BungieAPIError); + expect(error.errorCode).toBe(5); + expect(error.errorStatus).toBe('SystemDisabled'); + }); + + it('raises an untyped error for a plain HTTP failure', async () => { + // A 5xx is not a Bungie-level error — the body may not even be JSON — so it + // must not masquerade as one. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('gateway timeout', { status: 504 })) + ); + + const error = await makeClient().getPGCR('123').catch((e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(BungieAPIError); + expect(error.message).toContain('504'); + }); + + it('survives a non-JSON error body without masking the failure', async () => { + // Cloudflare serves HTML error pages. The 1672 inspection parses the body, + // so it must swallow the parse failure and still throw. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('

502

', { status: 502 })) + ); + + await expect(makeClient().getPGCR('123')).rejects.toThrow(/502/); + }); +}); + +describe('throttle handling pauses the whole key', () => { + it('defers the next request after Bungie reports a throttle', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ ...PGCR_BODY, ThrottleSeconds: 4 })) + ); + const client = makeClient(); + + await client.getPGCR('123'); + + const second = trackSettled(client.getPGCR('456')); + await vi.advanceTimersByTimeAsync(3999); + expect(second.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(20); + expect(second.settled).toBe(true); + }); + + it('honours Retry-After on an HTTP 429', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('rate limited', { status: 429, headers: { 'Retry-After': '7' } }) + ) + ); + const client = makeClient(); + + await client.getPGCR('123').catch(() => {}); + + const second = trackSettled(client.getPGCR('456').catch(() => {})); + await vi.advanceTimersByTimeAsync(6999); + expect(second.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(20); + expect(second.settled).toBe(true); + }); + + it('falls back to a five second pause when 429 omits Retry-After', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 }))); + const client = makeClient(); + + await client.getPGCR('123').catch(() => {}); + + const second = trackSettled(client.getPGCR('456').catch(() => {})); + await vi.advanceTimersByTimeAsync(4999); + expect(second.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(20); + expect(second.settled).toBe(true); + }); + + it('imposes its own backoff for a game-server throttle, which reports no duration', async () => { + // ErrorCode 1672 arrives as a 503 with ThrottleSeconds: 0. Bungie tells us + // to back off without saying how long, so retrying immediately would just + // earn another 503. Default self-imposed pause is 2s. + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ ErrorCode: 1672, ErrorStatus: 'DestinyThrottledByGameServer', ThrottleSeconds: 0 }), + { status: 503 } + ) + ) + ); + const client = makeClient(); + + await client.getPGCR('123').catch(() => {}); + + const second = trackSettled(client.getPGCR('456').catch(() => {})); + await vi.advanceTimersByTimeAsync(1999); + expect(second.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(20); + expect(second.settled).toBe(true); + }); +}); + +function trackSettled(promise: Promise): { settled: boolean } { + const state = { settled: false }; + promise.then( + () => { state.settled = true; }, + () => { state.settled = true; } + ); + return state; +} diff --git a/src/lib/bungie/maintenance.test.ts b/src/lib/bungie/maintenance.test.ts new file mode 100644 index 0000000..bba0c74 --- /dev/null +++ b/src/lib/bungie/maintenance.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { BungieAPIError } from './client'; +import { isBungieSystemDisabledError } from './maintenance'; + +/** + * This predicate decides whether the crawler and scanner pause for Bungie's + * weekly maintenance window or keep hammering a dead API. The cost of a false + * negative is thousands of doomed requests; the cost of a false positive is a + * needless multi-minute pause during an ordinary blip. Both matter, so the + * negative cases below are as important as the positive one. + */ +describe('isBungieSystemDisabledError', () => { + it('recognises Bungie signalling that the platform is down for maintenance', () => { + const error = new BungieAPIError(5, 'SystemDisabled', 'This system is temporarily disabled.'); + + expect(isBungieSystemDisabledError(error)).toBe(true); + }); + + it('ignores a different Bungie-level error', () => { + // A privacy restriction is a per-player condition, not a platform outage. + // Pausing the whole crawler for one private profile would be a real bug. + const error = new BungieAPIError(1665, 'DestinyPrivacyRestriction', 'Profile is private.'); + + expect(isBungieSystemDisabledError(error)).toBe(false); + }); + + it('ignores a plain HTTP failure', () => { + // request() throws a generic Error for non-2xx responses, so a 500 never + // reaches here as a BungieAPIError. Bungie 5xx storms are common and must + // not be mistaken for scheduled maintenance. + const error = new Error('Bungie API error 500: Internal Server Error'); + + expect(isBungieSystemDisabledError(error)).toBe(false); + }); + + it('ignores a request timeout', () => { + const error = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + + expect(isBungieSystemDisabledError(error)).toBe(false); + }); + + it('ignores an error-shaped object that merely claims the right status', () => { + // The check is instanceof-based, so a plain object carrying the same + // fields is deliberately not enough. + const impostor = { name: 'BungieAPIError', errorCode: 5, errorStatus: 'SystemDisabled' }; + + expect(isBungieSystemDisabledError(impostor)).toBe(false); + }); + + it('ignores non-errors entirely', () => { + expect(isBungieSystemDisabledError(null)).toBe(false); + expect(isBungieSystemDisabledError(undefined)).toBe(false); + expect(isBungieSystemDisabledError('SystemDisabled')).toBe(false); + }); +}); diff --git a/src/lib/utils/rate-limiter.test.ts b/src/lib/utils/rate-limiter.test.ts new file mode 100644 index 0000000..c61be11 --- /dev/null +++ b/src/lib/utils/rate-limiter.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RateLimiter } from './rate-limiter'; + +/** + * The limiter is the only thing standing between the crawler/scanner worker pools + * and Bungie's per-key rate limits. Its two guarantees — that concurrent waiters + * cannot claim the same slot, and that a pause applies to the whole key rather + * than to one request — are both timing behaviours, so they are tested with fake + * timers rather than real sleeps. + */ + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('request spacing', () => { + it('lets the first request through without waiting', async () => { + vi.useFakeTimers(); + const limiter = new RateLimiter(10); + + const granted = trackSettled(limiter.wait()); + await vi.advanceTimersByTimeAsync(0); + + expect(granted.settled).toBe(true); + }); + + it('spaces the next request by the configured interval', async () => { + vi.useFakeTimers(); + const limiter = new RateLimiter(10); // 100ms between grants + + limiter.wait(); + const second = trackSettled(limiter.wait()); + + await vi.advanceTimersByTimeAsync(99); + expect(second.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(second.settled).toBe(true); + }); + + it('serializes concurrent waiters instead of letting them burst', async () => { + // Without the FIFO promise chain, ten simultaneous callers would each read + // the same nextSlot and fire at once — the exact burst the limiter exists + // to prevent. + vi.useFakeTimers(); + const limiter = new RateLimiter(10); + + const order: number[] = []; + const waiters = Array.from({ length: 5 }, (_, i) => + limiter.wait().then(() => order.push(i)) + ); + + await vi.advanceTimersByTimeAsync(500); + await Promise.all(waiters); + + expect(order).toEqual([0, 1, 2, 3, 4]); + }); +}); + +describe('pausing the key', () => { + it('defers a request that had not started waiting yet', async () => { + vi.useFakeTimers(); + const limiter = new RateLimiter(1000); // spacing is negligible here + + limiter.pauseFor(5); + const granted = trackSettled(limiter.wait()); + + await vi.advanceTimersByTimeAsync(4999); + expect(granted.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(granted.settled).toBe(true); + }); + + it('extends a wait that is already in progress', async () => { + // The loop in wait() re-reads nextSlot after each sleep precisely so a + // pause landing mid-sleep is honoured. Without that re-read, a request + // that was already sleeping would fire straight into a throttled key. + vi.useFakeTimers(); + const limiter = new RateLimiter(1000); + + limiter.pauseFor(2); + const granted = trackSettled(limiter.wait()); + + await vi.advanceTimersByTimeAsync(1000); + limiter.pauseFor(5); // arrives while the first wait is still sleeping + + await vi.advanceTimersByTimeAsync(1000); + expect(granted.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(4000); + expect(granted.settled).toBe(true); + }); + + it('holds back every queued waiter, not just the one that saw the throttle', async () => { + // Bungie throttles the key, not the request. A pause that only affected + // the caller who observed it would let the rest of the pool keep hammering. + vi.useFakeTimers(); + const limiter = new RateLimiter(1000); + + // Let one waiter through first, so the pause below is demonstrably + // affecting a queue rather than simply being set before any activity. + const first = trackSettled(limiter.wait()); + await vi.advanceTimersByTimeAsync(0); + expect(first.settled).toBe(true); + + const queued = [trackSettled(limiter.wait()), trackSettled(limiter.wait())]; + limiter.pauseFor(3); + + await vi.advanceTimersByTimeAsync(2999); + expect(queued.map((w) => w.settled)).toEqual([false, false]); + + // Both are released once the pause lifts; the extra tick covers the + // normal inter-request spacing between the two of them. + await vi.advanceTimersByTimeAsync(20); + expect(queued.every((w) => w.settled)).toBe(true); + }); + + it('catches a waiter that has been queued but not yet granted', async () => { + // Grants are handed out on a microtask, so a pause issued in the same tick + // as wait() still applies — the caller is queued, not yet through. + vi.useFakeTimers(); + const limiter = new RateLimiter(1000); + + const granted = trackSettled(limiter.wait()); + limiter.pauseFor(3); + + await vi.advanceTimersByTimeAsync(2999); + expect(granted.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(granted.settled).toBe(true); + }); + + it('never shortens an existing pause', async () => { + // pauseFor takes the max, so a 1s game-server backoff arriving during a + // 10s throttle must not cut the longer pause short. + vi.useFakeTimers(); + const limiter = new RateLimiter(1000); + + limiter.pauseFor(10); + limiter.pauseFor(1); + const granted = trackSettled(limiter.wait()); + + await vi.advanceTimersByTimeAsync(9999); + expect(granted.settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(granted.settled).toBe(true); + }); +}); + +function trackSettled(promise: Promise): { settled: boolean } { + const state = { settled: false }; + promise.then( + () => { state.settled = true; }, + () => { state.settled = true; } + ); + return state; +} From e0115f84af51e9af322f5b7788d0759f093a13ee Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:14:36 -0400 Subject: [PATCH 10/17] ci: run lint, typecheck, and tests on push and PR (phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First workflow in the repo. Node 22, matching local and production. Adds `tsc --noEmit` beyond the brief: nothing else typechecks the repo, since vitest transpiles without checking and a full `next build` is too slow for a per-push gate. The tsconfig includes **/*.ts, so test files are checked too. The e2e maintenance harness is excluded — it spawns long-running processes against a mock Bungie server. Documented in the workflow: CI is x86_64 while production is ARM64 (Oracle A1 Flex), so native-module differences in better-sqlite3 will not be caught here. --- .github/workflows/test.yml | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..168a1da --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: test + +# Fast, hermetic checks only. The maintenance-cycle harness +# (`npm run e2e:maintenance`) is deliberately excluded: it spawns real +# crawler and scanner processes against a mock Bungie server and runs for +# minutes, which is not what a per-push gate is for. +# +# NOTE ON ARCHITECTURE: this runs on x86_64, while production is ARM64 +# (Oracle A1 Flex). better-sqlite3 is a native module, so CI compiles and +# tests a different binary than production runs. Native-level differences +# in better-sqlite3 will not be caught here. + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # `ci`, not `install`: the lockfile is the source of truth for + # deployments, and a CI run that silently resolved different versions + # would be testing something production never sees. + - run: npm ci + + - run: npm run lint + + # Nothing else typechecks the repo — `npm test` transpiles without + # checking, and a full `next build` is too slow for this gate. + - run: npx tsc --noEmit + + - run: npm test From 424c38b1963d615180b129f701e65f0b2d92198d Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:16:08 -0400 Subject: [PATCH 11/17] test: cover processPGCR and the real full-clear signal (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brief made ProcessedPGCR.isFullClear the centrepiece here, calling its three-way || the riskiest logic in the codebase. It is dead code: computed, returned, and never read. fetchAndStorePGCR persists Bungie's raw activityWasStartedFromBeginning instead, and every leaderboard filters on that column. It is also wrong. Bungie no longer sends startingPhaseIndex (absent on all 827k stored rows), so the `=== undefined` branch fires unconditionally and reports every run as a full clear — including the 568k checkpoint runs. Harmless only because nothing consumes it; wiring it up would inflate every leaderboard by roughly 2.2x. So Phase 3 tests the signal that actually ships: - pgcr.test.ts: the per-entry .some() completion check, director hash vs referenceId fallback, raid key resolution, ISO-to-unix conversion (including a DST boundary, to pin that no local offset leaks in), and player extraction tolerating a withheld global display name. - full-clear-flag.test.ts: that checkpoint runs persist as 0, that starting_phase_index is inert, and that the full-clear flag stays independent of whether anyone finished. isFullClear is left untested on purpose, with the reasoning recorded in the test file — pinning dead behaviour would only make its removal harder. Flagged for deletion in a future change, not deleted here. --- src/lib/crawler/pgcr.test.ts | 162 +++++++++++++++++++++++++++++++ tests/db/full-clear-flag.test.ts | 66 +++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/lib/crawler/pgcr.test.ts create mode 100644 tests/db/full-clear-flag.test.ts diff --git a/src/lib/crawler/pgcr.test.ts b/src/lib/crawler/pgcr.test.ts new file mode 100644 index 0000000..6e801c7 --- /dev/null +++ b/src/lib/crawler/pgcr.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { processPGCR } from './pgcr'; +import { + NON_RAID_HASH, + RAID_HASH, + buildEntry, + buildFireteam, + buildPGCR, +} from '../../../tests/helpers/pgcr-builder'; + +/** + * `processPGCR` is pure — JSON in, object out — so it needs no setup and is the + * right first target. It is the funnel every ingested raid passes through. + * + * NOT COVERED HERE, DELIBERATELY: `ProcessedPGCR.isFullClear`. That field is + * computed and returned but never read by anything — `fetchAndStorePGCR` + * persists Bungie's raw `activityWasStartedFromBeginning` instead, and every + * leaderboard filters on that column. The derivation is also wrong: Bungie no + * longer sends `startingPhaseIndex` (absent on all 827k stored rows), so the + * `=== undefined` branch fires unconditionally and reports every run as a full + * clear. It is dead code slated for removal, so pinning its behaviour here would + * only make that removal harder. The signal that actually decides leaderboard + * membership is covered in tests/db/full-clear-flag.test.ts. + * See docs/decisions.md. + */ + +describe('run completion', () => { + it('counts the run as completed when a single member finished', () => { + // `completed` is per-entry and the check is an .some(): five people can + // leave and the run still counts, which is correct — the raid was cleared. + const pgcr = buildPGCR({ entries: buildFireteam({ size: 6, completions: 1 }) }); + + expect(processPGCR(pgcr).completed).toBe(true); + }); + + it('counts the run as not completed when nobody finished', () => { + // 55% of stored PGCRs land here, so this is the common path, not the edge. + const pgcr = buildPGCR({ entries: buildFireteam({ size: 6, completions: 0 }) }); + + expect(processPGCR(pgcr).completed).toBe(false); + }); + + it('counts the run as not completed when it has no entries at all', () => { + expect(processPGCR(buildPGCR({ entries: [] })).completed).toBe(false); + }); + + it('treats a missing completion stat as not completed', () => { + // Old PGCRs omit stats rather than reporting zero. + const entry = buildEntry(); + delete entry.values.completed; + + expect(processPGCR(buildPGCR({ entries: [entry] })).completed).toBe(false); + }); +}); + +describe('activity identification', () => { + it('prefers the director activity hash', () => { + const pgcr = buildPGCR({ activityHash: RAID_HASH, referenceId: NON_RAID_HASH }); + + expect(processPGCR(pgcr).activityHash).toBe(RAID_HASH); + }); + + it('falls back to the reference id when the director hash is absent', () => { + // Bungie reports directorActivityHash as 0 for some older activities, and + // `||` treats that as absent — which is the intended behaviour here. + const pgcr = buildPGCR({ activityHash: 0, referenceId: RAID_HASH }); + + expect(processPGCR(pgcr).activityHash).toBe(RAID_HASH); + }); + + it('resolves a known raid to its key', () => { + const pgcr = buildPGCR({ activityHash: RAID_HASH }); + + expect(processPGCR(pgcr).raidKey).toBe('salvations_edge'); + }); + + it('leaves the raid key unset for an activity that is not a raid', () => { + // The crawler drops non-raids on this basis, so a wrong answer here either + // floods the database with strikes or silently discards real raids. + const pgcr = buildPGCR({ activityHash: NON_RAID_HASH }); + + expect(processPGCR(pgcr).raidKey).toBeUndefined(); + }); + + it('carries the instance id through unchanged', () => { + // Instance ids exceed 2^31 and are handled as strings throughout; any + // numeric coercion would corrupt them. + const pgcr = buildPGCR({ instanceId: '17091392013' }); + + expect(processPGCR(pgcr).instanceId).toBe('17091392013'); + }); +}); + +describe('period conversion', () => { + it('converts Bungie\'s ISO timestamp to unix seconds', () => { + const pgcr = buildPGCR({ period: '2026-07-26T12:00:00Z' }); + + expect(processPGCR(pgcr).period).toBe(Math.floor(Date.UTC(2026, 6, 26, 12, 0, 0) / 1000)); + }); + + it('is unaffected by a daylight-saving boundary', () => { + // Bungie reports UTC, which has no DST. This pins that the conversion does + // not pick up the host's local offset — a machine in a DST-observing zone + // would otherwise shift every run by an hour twice a year, quietly moving + // runs across leaderboard cutoffs. + const pgcr = buildPGCR({ period: '2026-03-29T01:30:00Z' }); + + expect(processPGCR(pgcr).period).toBe(Math.floor(Date.UTC(2026, 2, 29, 1, 30, 0) / 1000)); + }); + + it('truncates sub-second precision rather than rounding up', () => { + const pgcr = buildPGCR({ period: '2026-07-26T12:00:00.999Z' }); + + expect(processPGCR(pgcr).period).toBe(Math.floor(Date.UTC(2026, 6, 26, 12, 0, 0) / 1000)); + }); +}); + +describe('player extraction', () => { + it('keeps one record per entry', () => { + const pgcr = buildPGCR({ entries: buildFireteam({ size: 6 }) }); + + expect(processPGCR(pgcr).players).toHaveLength(6); + }); + + it('preserves the parts that make up a Name#Code identity', () => { + // Player identity is Name#Code throughout the system; dropping either half + // here produces the partial-name bug the project has hit before. + const entry = buildEntry({ + membershipId: '4611686018400000001', + bungieGlobalDisplayName: 'Guardian', + bungieGlobalDisplayNameCode: 42, + }); + + const [player] = processPGCR(buildPGCR({ entries: [entry] })).players; + + expect(player.bungieGlobalDisplayName).toBe('Guardian'); + expect(player.bungieGlobalDisplayNameCode).toBe(42); + }); + + it('tolerates an entry with no global display name', () => { + // Bungie withholds it for some accounts. Extraction must not throw; the + // downstream display path falls back to the platform display name. + const entry = buildEntry({ + displayName: 'LegacyName', + bungieGlobalDisplayName: null, + bungieGlobalDisplayNameCode: null, + }); + + const [player] = processPGCR(buildPGCR({ entries: [entry] })).players; + + expect(player.bungieGlobalDisplayName).toBeUndefined(); + expect(player.displayName).toBe('LegacyName'); + }); + + it('retains every member of an oversized fireteam', () => { + // Entry counts above six are normal: players joining and leaving each get + // an entry, and the database has raids with seven or more. + const pgcr = buildPGCR({ entries: buildFireteam({ size: 9 }) }); + + expect(processPGCR(pgcr).players).toHaveLength(9); + }); +}); diff --git a/tests/db/full-clear-flag.test.ts b/tests/db/full-clear-flag.test.ts new file mode 100644 index 0000000..450212d --- /dev/null +++ b/tests/db/full-clear-flag.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { resetTestDb } from '../helpers/db'; +import { readPgcrRow, seedRun } from '../helpers/seed'; + +/** + * What actually decides whether a run counts as a full clear. + * + * There are two candidate signals in the codebase and only one of them is real: + * + * ProcessedPGCR.isFullClear computed, never persisted, always true + * pgcrs.activity_was_started_from_beginning persisted, and what every + * leaderboard filters on + * + * The first is dead code. Bungie stopped sending `startingPhaseIndex` — it is + * absent on all 827k stored rows — so `isFullClear`'s `=== undefined` branch + * fires unconditionally and reports every run as a full clear, including the + * 568k that are checkpoint runs. Nothing reads it, so nothing breaks today; + * wiring it up would inflate every leaderboard by roughly 2.2x. + * + * These tests pin the signal that ships, so that if anyone ever "tidies" the + * writer by using the derived field instead, the failure is loud. + * See docs/decisions.md. + */ + +beforeEach(() => { + resetTestDb(); +}); + +describe('the persisted full-clear flag', () => { + it('records a run started from the beginning as a full clear', () => { + seedRun({ instanceId: '1', completedBy: ['p1'], startedFromBeginning: true }); + + expect(readPgcrRow('1')?.activity_was_started_from_beginning).toBe(1); + }); + + it('records a checkpoint run as not a full clear', () => { + // The case ProcessedPGCR.isFullClear gets wrong. If the writer ever + // switched to that field, this would flip to 1 and the run would start + // appearing on full-clear leaderboards. + seedRun({ instanceId: '1', completedBy: ['p1'], startedFromBeginning: false }); + + expect(readPgcrRow('1')?.activity_was_started_from_beginning).toBe(0); + }); + + it('stores a zero starting phase index regardless of the run type', () => { + // Bungie no longer sends startingPhaseIndex and the writer coerces it with + // `|| 0`, so the column is 0 for every row in production. Pinned so nobody + // writes a query that assumes this column still discriminates anything. + seedRun({ instanceId: '1', completedBy: ['p1'], startedFromBeginning: true }); + seedRun({ instanceId: '2', completedBy: ['p1'], startedFromBeginning: false }); + + expect(readPgcrRow('1')?.starting_phase_index).toBe(0); + expect(readPgcrRow('2')?.starting_phase_index).toBe(0); + }); + + it('keeps the full-clear flag independent of whether anyone finished', () => { + // Two orthogonal facts: how the run was entered, and whether it was + // cleared. The leaderboards require both, so conflating them would either + // admit checkpoint clears or exclude legitimate ones. + seedRun({ instanceId: '1', incompleteBy: ['p1'], startedFromBeginning: true }); + + const row = readPgcrRow('1'); + expect(row?.activity_was_started_from_beginning).toBe(1); + expect(row?.completed).toBe(0); + }); +}); From 55ff85b3cb6be560e326537641de93727c327d4a Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:19:10 -0400 Subject: [PATCH 12/17] docs: record testing decisions, findings, and glossary terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0003 — test databases are real SQLite files, not `:memory:`. SQLite silently downgrades journal_mode=WAL to 'memory' for in-memory databases, so `:memory:` would exercise different journal semantics than production and give up most of the reason for using a real database. ADR 0004 — mock only at the network boundary. No vi.mock of our own modules, real database, seeding through insertFullPGCR. Written down because mocking one's own modules is the default habit elsewhere, so the absence needs explaining before someone adds it back. CONTEXT.md — Full Clear sharpened to name the only authoritative signal, since the codebase held two competing implementations of the term and the glossary did not say which was canonical. Added Checkpoint Run (the majority of observed raids, previously unnamed) and Completion (the unit leaderboards actually rank by, and narrower than "finished a raid"). decisions.md — the recon findings that reshaped the work, plus four defects reported rather than fixed: dead-and-wrong isFullClear, formatDisplayName dropping #Code on a zero code, getDb() reading the maintenance state file on every call, and CLAUDE.md describing a runtime manifest dependency that does not exist. Also brings ADRs 0001 and 0002 into git; they were written earlier but never committed. --- CONTEXT.md | 56 +++++ .../0001-fireteam-denominated-display-cap.md | 24 ++ .../0002-session-count-reports-true-total.md | 22 ++ ...03-tests-run-against-a-real-sqlite-file.md | 45 ++++ .../0004-mock-only-at-the-network-boundary.md | 41 ++++ docs/decisions.md | 207 ++++++++++++++++++ tests/README.md | 111 ++++++++++ tests/fixtures/README.md | 55 +++++ 8 files changed, 561 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-fireteam-denominated-display-cap.md create mode 100644 docs/adr/0002-session-count-reports-true-total.md create mode 100644 docs/adr/0003-tests-run-against-a-real-sqlite-file.md create mode 100644 docs/adr/0004-mock-only-at-the-network-boundary.md create mode 100644 docs/decisions.md create mode 100644 tests/README.md create mode 100644 tests/fixtures/README.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..d87a1cb --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,56 @@ +# Destiny Farm Finder + +Tracks Destiny 2 raid activity in near real time: which players are raiding right now, and who +has completed what. A set of background crawlers observes the Bungie API and writes to SQLite; +the web app only reads. + +## Language + +**Fireteam**: +A group of players playing a Destiny activity together. The unit users care about — every card on +the active-sessions page is one fireteam. +_Avoid_: party, group, team, squad, lobby + +**Active Session**: +A fireteam observed to be inside a raid right now. Ceases to be active when the crawler confirms +the raid ended, or when the observation goes stale. +_Avoid_: live session, current activity, in-progress raid + +**Roster**: +The players making up a fireteam, as reported by Bungie. May be incomplete — Bungie does not +always disclose every member — so a roster of one usually means limited visibility rather than a +genuine solo run. +_Avoid_: party members, participants, players in session + +**Tracked Player**: +A player the system knows about and will poll for activity. Identified by `Name#Code`; a player +becomes tracked by being discovered in a raid alongside someone already tracked. +_Avoid_: user, account, member + +**Raid**: +Destiny's six-player endgame activity, and the only activity type the leaderboards and the +active-sessions list cover. Other activities are observed but never displayed. +_Avoid_: activity (too broad), instance + +**Full Clear**: +A raid played from the first encounter through the final boss, as opposed to joining at a +checkpoint. Only Bungie's own report that the activity began at the start establishes this — no +other signal is authoritative, and one that looks like it is has been wrong since Bungie stopped +publishing it. +_Avoid_: complete run, fresh run + +**Checkpoint Run**: +A raid entered partway through, at a saved encounter. Observed and stored like any other run, but +never counted toward a leaderboard. The majority of raids we see. +_Avoid_: partial run, CP run + +**Completion**: +One full clear finished by a particular player, counted once per raid instance however many +characters they brought to it. The unit every leaderboard ranks by. A player being present for a +cleared raid is not enough — they must have finished it themselves. +_Avoid_: clear, kill, run + +**Farm**: +Repeatedly replaying a single raid encounter or checkpoint for rewards, rather than progressing +through the raid. The activity the site is named for. +_Avoid_: grind, rerun diff --git a/docs/adr/0001-fireteam-denominated-display-cap.md b/docs/adr/0001-fireteam-denominated-display-cap.md new file mode 100644 index 0000000..8d903a9 --- /dev/null +++ b/docs/adr/0001-fireteam-denominated-display-cap.md @@ -0,0 +1,24 @@ +# Active-session limits are denominated in fireteams, not rows + +`active_sessions` is keyed by `membership_id`, so a single fireteam produces up to six rows — one +per tracked player in it. The read path originally capped those raw rows (`ORDER BY started_at +DESC LIMIT 200`) and deduped into fireteams afterwards, which meant the limit was spent on +duplicates and, because it sorted by start time, evicted the longest-running raids first. In +practice ~1000 live rows rendered ~110 cards and nothing older than about five minutes was ever +visible. We now scan a generous bound of raw rows, dedupe into fireteams, and only then apply the +user-facing cap — which is counted in fireteams. + +## Consequences + +- Two separate limits exist and must not be collapsed into one: `ACTIVE_SESSION_ROW_SCAN_LIMIT` + (raw rows, default 3000) and `ACTIVE_SESSION_DISPLAY_LIMIT` (fireteams, default 600). + Re-introducing a single `LIMIT` in SQL restores the bug. +- The row scan is ordered by `checked_at DESC`, not `started_at DESC`. It is served by + `idx_active_sessions_checked_at`, and if the bound is ever hit it sheds the *stalest* rows — + the ones closest to ageing out — instead of the longest-running raids. +- Dedupe happens before name enrichment, so the display-name lookup covers only the fireteams + actually rendered rather than every row scanned. +- The row bound has roughly 2x headroom over what the crawler can produce: at + `CRAWLER_SESSION_POLLING_LIMIT` rows per cycle across the 900s freshness window, at most ~1500 + rows can be fresh simultaneously. Raising the polling limit materially should prompt a review + of this bound. diff --git a/docs/adr/0002-session-count-reports-true-total.md b/docs/adr/0002-session-count-reports-true-total.md new file mode 100644 index 0000000..91e0556 --- /dev/null +++ b/docs/adr/0002-session-count-reports-true-total.md @@ -0,0 +1,22 @@ +# The active-session count reports the true total, not the number of cards shown + +`countActiveRaidSessions` feeds the nav StatsBar and the OG share cards, while +`/api/active-sessions` feeds the page. These deliberately no longer agree: the count reports every +live fireteam, whereas the list is capped at `ACTIVE_SESSION_DISPLAY_LIMIT`. The headline number +answers "how busy is Destiny right now", which is the question a share card and a stats bar are +actually asking; capping it to whatever happened to fit on screen would understate real activity +and undersell the site. + +This is a deliberate exception to the invariant that `dedupe.ts` was written to protect — that the +count and the list collapse duplicate rows identically. That invariant still holds: both go through +`getDedupedActiveSessions`, so they can never disagree about *what a fireteam is*. Only the cap +differs. + +## Consequences + +- `/api/active-sessions` returns `total` (all live fireteams) alongside `shown` (those under the + cap), so the page can disclose the difference rather than hiding sessions silently. +- A discrepancy between the StatsBar number and the visible card count is expected when the cap + bites, and is not a bug to be "fixed" by capping the count. +- The server logs a warning whenever the cap bites, since the default (600) sits close to observed + prod volume and the gap would otherwise be invisible. diff --git a/docs/adr/0003-tests-run-against-a-real-sqlite-file.md b/docs/adr/0003-tests-run-against-a-real-sqlite-file.md new file mode 100644 index 0000000..788039e --- /dev/null +++ b/docs/adr/0003-tests-run-against-a-real-sqlite-file.md @@ -0,0 +1,45 @@ +# Tests run against a real SQLite file, not `:memory:` + +The test suite gives each test file its own throwaway database in a `mkdtemp` +directory, pointed at by `RAID_TRACKER_DB_PATH`, rather than using SQLite's +`:memory:` database. `:memory:` looks like the obvious choice — faster, no +cleanup — so the reason for not using it needs recording. + +## Why + +SQLite cannot put an in-memory database into WAL mode. `PRAGMA journal_mode = WAL` +returns `memory` for `:memory:` and `wal` for a file, silently: + +``` +:memory: journal_mode = WAL -> 'memory' +file journal_mode = WAL -> 'wal' +``` + +Production runs WAL. The entire justification for testing against a real database +rather than a mock is that it validates the real SQL under real semantics, so +running the suite under a different journal mode gives up most of what the +approach was bought for. + +A temp *directory* rather than just a temp file, because `DATA_DIR` in +`src/lib/maintenance/state.ts` derives from `dirname(RAID_TRACKER_DB_PATH)`. +Relocating the database therefore relocates `maintenance-state.json` for free. +That is not incidental: `getDb()` calls `isDbQuiesceActive()` on *every* +invocation, which reads that file from disk — so a suite pointed at the real data +directory would throw `DatabaseMaintenanceError` from every test if it happened +to run while a maintenance vacuum was in progress. + +The path is set in a Vitest `setupFile` rather than inside a helper, because +`DB_PATH` is a module-level constant resolved at import time. Setting it before +the test file's own imports run is what lets test files use ordinary static +imports instead of `await import()` throughout. + +## Consequences + +- Test databases cost a `mkdtemp` plus `initializeSchema()` per test file — + roughly 5–15 ms on tmpfs. At this suite's size that is a few milliseconds + overall, well below the value of matching production semantics. +- Temp directories leak into the system temp dir if a test process is killed + before `afterAll` runs. Harmless, and the OS clears them. +- The schema under test is the production schema by construction: `getDb()` runs + `initializeSchema()`, including the `ended_at` migration guard and the Phase 3 + indexes. There is no second schema definition that can drift. diff --git a/docs/adr/0004-mock-only-at-the-network-boundary.md b/docs/adr/0004-mock-only-at-the-network-boundary.md new file mode 100644 index 0000000..d3d0d42 --- /dev/null +++ b/docs/adr/0004-mock-only-at-the-network-boundary.md @@ -0,0 +1,41 @@ +# Mock only at the network boundary + +Tests stub `fetch` and nothing else. There is no `vi.mock()` of any module in +`src/`, and the database is real rather than faked. This is a deliberate +constraint, not an oversight — mocking our own modules is the default habit in +most test suites, so the absence needs explaining before someone helpfully adds it. + +## Why + +Leaderboard integrity is the product. The failure that matters here is not a +crash but a silently wrong row set, and that failure lives in exactly the places +mocking would erase: the SQL, and the shape of what Bungie actually returns. + +- **Mocking `@/lib/db/queries` would test the mock.** A test asserting that + `getLeaderboard` returns what the mock was told to return proves nothing about + whether the SQL selects the right runs. `better-sqlite3` opens a database in + about a millisecond, so a real one is both faster than the mock scaffolding and + actually load-bearing. +- **Seeding goes through `insertFullPGCR`, not raw INSERTs.** All four production + ingestion sources funnel through that function, and it is where `ended_at` is + derived and `players.last_seen_at` advanced. Raw inserts would let tests build + rows that production could never produce, so the tests would pass against + impossible data. +- **`fetch` is the one boundary worth faking.** It is genuinely external, genuinely + slow, rate-limited, and returns different data every day. Everything on our side + of it is ours to verify. + +A setup file (`tests/setup/no-network.ts`) replaces `fetch` with a thrower before +every test, so a test that reaches the real internet fails loudly instead of +quietly burning Bungie API quota and going flaky against live data. + +## Consequences + +- Test databases are real; see ADR 0003 for why they are files rather than + `:memory:`. +- Tests that need a specific Bungie response stub `fetch` explicitly. The guard + records itself as the original, so the block is restored automatically for the + next test with no per-file cleanup. +- Fixtures are captured from the live API rather than hand-authored, so they + encode Bungie's real quirks instead of our beliefs about them. Builders in + `tests/helpers/` cover permutations where only one field needs to vary. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..8d96d60 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,207 @@ +# Decisions + +Running log of decisions that aren't big enough for an ADR, or that live partly outside the +codebase (infrastructure, Cloudflare config) where a code comment can't reach them. + +--- + +## 2026-07-26 — HTTP caching for the real-time API routes + +### Symptom + +After deploying the fireteam-denominated display cap (`f760d58`), the browser stopped showing +live numbers: + +- `/api/live-stats` and `/api/active-sessions?limit=600` both served as `200 OK (from disk cache)`. +- The StatsBar's full-clear count and active-fireteam count sat unchanged for roughly an hour. +- `/api/active-sessions?limit=600` fired **twice** per poll — one from disk cache, one a real + 200 or 304. + +### The display-cap change was not the cause + +Confirmed, not assumed: + +``` +git show f760d58 -- src/app/api/active-sessions/route.ts \ + src/app/api/live-stats/route.ts \ + src/lib/http/cache.ts + | grep -E '^[-+].*(withCache|withNoStore|Cache|max-age|dynamic)' +→ no matches +``` + +`src/lib/http/cache.ts` had not been touched since 2026-05-29 (`51414df`). The +`withCache(…, 10, 30)` on active-sessions and `withCache(…, 15, 30)` on live-stats predate that +work by two months. The change altered *which* numbers those endpoints return, never how they are +cached. + +The origin was also verified healthy — three cache-busted fetches 18s apart returned 408, 412 and +411 fireteams with an advancing `timestamp`. Nothing was frozen server-side. + +### Root cause + +`cacheControl()` emits `public, max-age=0, s-maxage=N, stale-while-revalidate=M`. Nothing in the +repo has ever emitted a non-zero `max-age`. The value reaching the browser was being rewritten by +Cloudflare, and the three endpoints differed in a way that pinned it exactly: + +| endpoint | Cloudflare cache rule | `max-age` at the browser | `cf-cache-status` | +|---|---|---|---| +| `/api/status` | none | `0` — origin value, untouched | `DYNAMIC` | +| `/api/active-sessions` | Browser TTL: override, 1s | `1` | `HIT` / `EXPIRED` | +| `/api/live-stats` | Browser TTL: **unset** | `14400` | `HIT` / `EXPIRED` | + +Leaving Browser TTL unset in a cache rule does **not** pass the origin header through. It falls +back to the zone-level Browser Cache TTL (Caching → Configuration), whose default is 4 hours — +hence `max-age=14400`, hence a browser entitled to serve the stat bar from disk for four hours +without asking. `/api/status`, which has no cache rule and is therefore not eligible for cache, +proves the contrast: its `max-age=0` arrives unmodified. + +### Decision: fix it at Cloudflare, not at the origin + +~15s of caching is wanted, not merely tolerated — it shields the SQLite dedupe pass from repeated +polling across two PM2 workers. Cloudflare is the correct layer to express "cache 15s at the edge, +never in the browser", because it can separate edge TTL from browser TTL. The origin header cannot. + +**Applied:** Browser TTL → *override origin, 1 second* on the `/api/live-stats` cache rule, +mirroring the rule already on `/api/active-sessions`. Verified afterwards: + +``` +/api/live-stats cache-control: public, max-age=1, s-maxage=15, stale-while-revalidate=30 +/api/active-sessions cache-control: public, max-age=1, s-maxage=10, stale-while-revalidate=30 +``` + +The four-hour freeze is gone. + +### Trap: do not "fix" this with `withNoStore` + +The obvious-looking origin fix — switching both routes to `withNoStore` — was written, tested +against this analysis, and **reverted deliberately**. The `/api/live-stats` rule uses Edge TTL +*"use cache-control header if present, bypass cache if not"*. A `no-store` response makes +Cloudflare **bypass the edge cache entirely**, destroying the 15s edge caching the rule exists to +provide. The origin header and the cache rule have to be designed together; changing one in +isolation fights the other. + +`export const dynamic = 'force-dynamic'` was reverted for a different reason: it was never the +problem. Next.js 15+ does not cache GET route handlers by default, and prod was measurably dynamic +already. Harmless, but it would have been misleading documentation of a cause that wasn't real. + +### Still outstanding: the double fetch + +Cloudflare's Browser TTL override rewrites `max-age` but passes `stale-while-revalidate` through +untouched: + +``` +cache-control: public, max-age=1, s-maxage=10, stale-while-revalidate=30 + ^^^^^^^^^ Cloudflare ^^^^^^^^^^^^^^^^^^^^^^ origin, unmodified +``` + +Chrome implements SWR. Past `max-age=1` the copy is stale, so each 30s poll hands the page the +stale disk copy *immediately* and fires a background revalidation — the two network rows, and data +rendered up to ~31s old. This is the origin's `stale-while-revalidate=30` doing exactly what it +says; the directive is simply wrong for an endpoint that is polled on a fixed interval. + +**Recommended origin change (not yet made):** keep `withCache`, drop the +`stale-while-revalidate` term for the two real-time endpoints. Resulting behaviour: + +- Browser: `max-age` pinned to 1s by the cache rule, no SWR → every poll is a real conditional + request. One network row. +- Cloudflare edge: absorbs those polls at its configured TTL, one origin query per ~15s. +- Origin: unchanged cost. + +Open question before doing it: whether to drop SWR globally from `cacheControl()` or only for +these two. SWR is defensible on the slower-moving endpoints, so a second helper +(`withCacheNoStale`, or an optional third argument) is probably better than changing the shared +one. + +### Latent exposure elsewhere + +These still send `public` to the browser and would freeze the same way if a cache rule without a +Browser TTL were ever added for them: + +- `src/app/api/leaderboard/route.ts:41` +- `src/app/api/players/[membershipType]/[membershipId]/route.ts:176` +- `src/app/api/raids/route.ts:14` +- `src/app/api/status/route.ts:36` (healthy path only) + +Slower-moving data, so a stale read is less visible — but a player page stuck for four hours is the +same failure. **Durable mitigation:** set the zone-level Browser Cache TTL to *Respect Existing +Headers*. While it stays at the 4-hour default, every future cache rule written without an explicit +Browser TTL inherits this bug. + +### Consequences + +- Cloudflare cache rules are load-bearing configuration for this app, and they are not in the repo. + A rule added or edited without a Browser TTL reintroduces a multi-hour client-side freeze that + looks exactly like an origin bug and cannot be reproduced locally. +- When a "stale data" report arrives, check `cf-cache-status` and the `cache-control` actually + received before reading any application code. `curl -sS -D - -o /dev/null ` against prod + settles origin-vs-edge-vs-browser in one request; a cache-busted fetch confirms the origin + independently. +- `s-maxage` in `cacheControl()` is only honoured where a cache rule makes the path eligible. + Endpoints with no rule (`/api/status`) are `DYNAMIC` and their `s-maxage` is inert. + +--- + +## 2026-07-26 — Testing framework: what got built, and what the brief got wrong + +The repo had no unit test framework. Vitest is now wired up with tests covering `processPGCR`, +the leaderboard query, `ended_at` derivation, Bungie error handling, and the rate limiter. Full +plan of record: `docs/testing-framework-plan.md`. Strategy decisions: ADR 0003 (real SQLite files, +not `:memory:`) and ADR 0004 (mock only at the network boundary). + +Recorded here are the findings that changed the shape of the work, and the defects found along the +way that were **not** fixed. + +### `ProcessedPGCR.isFullClear` is dead code, and would be wrong if used + +`src/lib/crawler/pgcr.ts:36` derives `isFullClear` from a three-way `||`. Nothing reads it. +`fetchAndStorePGCR` persists Bungie's raw `activityWasStartedFromBeginning`, and every leaderboard +filters on that column (`leaderboard-cache.ts:175`, `queries.ts:760/781/820/855`). + +It is also incorrect. Bungie has stopped sending `startingPhaseIndex` — it is `0` on **all** +827,076 stored rows, meaning absent — so the `startingPhaseIndex === undefined` branch fires +unconditionally and the field is `true` for 100% of runs. Of those, 568,648 have +`activity_was_started_from_beginning = 0`, i.e. they are checkpoint runs. Wiring this field into +the writer would inflate every full-clear leaderboard by roughly 2.2×. + +**Action:** delete the field in a future change. Deliberately left untested — pinning dead +behaviour would only make removal harder. The reasoning is duplicated in +`src/lib/crawler/pgcr.test.ts` so whoever finds it there does not have to come looking here. + +### `formatDisplayName` drops the `#Code` when the code is zero + +`src/lib/cache/leaderboard-cache.ts:135` guards with +`if (entry.bungieGlobalDisplayName && entry.bungieGlobalDisplayNameCode)`. A code of `0` is falsy, +so the branch is skipped and the player renders as a bare name. CLAUDE.md calls the full +`Name#Code` form load-bearing and notes partial names were a real bug before. + +Pinned as current behaviour in `tests/db/leaderboard.test.ts`, labelled `BUG:` — not endorsed. Not +fixed here because the brief said to report defects rather than fix them, and because whether a +`#0000` code is reachable in Bungie's namespace was not established. + +### `getDb()` hits the filesystem on every call + +`isDbQuiesceActive()` reads `data/maintenance-state.json` from disk on **every** `getDb()` +invocation, not just on open. Not a correctness bug — but it is why test isolation has to relocate +`DATA_DIR` and not merely the database file, since a suite running during a maintenance vacuum +would otherwise fail every test with `DatabaseMaintenanceError`. + +### CLAUDE.md's raid-detection description is inaccurate + +CLAUDE.md states raid detection "matches `activityHash` against the manifest cache +(`data/manifest-cache.json`)". Nothing reads that file. `RAID_DEFINITIONS` is a hardcoded literal +in `src/lib/bungie/manifest.ts:16`; `setup-manifest` only *writes* the cache, for human review +before hand-editing the literal. Convenient for tests — raid detection is fully hermetic — but the +documentation implies a runtime dependency that does not exist. + +### The `ended_at` cutover was already complete + +The brief described it as in-flight and asked for a parity safety net across ~10 SQL sites. It +shipped in `610408e`; zero `run_durations` references remain in `src/`. That phase was retargeted +to testing `computeActivityDurationSeconds` — the tiered derivation that replaced the CTE — which +is where a wrong row set would now come from. + +### Zero-completion runs are the dominant shape + +456,009 of 827,076 stored PGCRs (55%) have no player with `completed = 1`. Not a defect, but worth +knowing before reasoning about any query that joins through completions: the "no completions" case +is the common path, not an edge case. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..901f2a3 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,111 @@ +# Tests + +## Running them + +```bash +npm test # everything, once. Fast, hermetic, no network. +npm run test:watch # re-runs on save +npm run test:coverage # adds a coverage table. No thresholds — it's a diagnostic, not a gate. + +npx vitest run tests/db/leaderboard.test.ts # one file +npx vitest run -t 'checkpoint' # tests whose name matches +``` + +`npm test` is meant to stay fast and reachable from anywhere. It never touches the network and +never touches the real database — if it ever does either, that's a bug in the test, not a +tolerable shortcut. + +## `npm test` vs `npm run e2e:maintenance` + +Two different things that both deserve to exist. + +| | `npm test` | `npm run e2e:maintenance` | +|---|---|---| +| What | Unit and query-level tests | The maintenance-cycle harness | +| Speed | Under a second | Minutes | +| Needs | Nothing | Spawns real crawler/scanner processes against a mock Bungie server | +| In CI | Yes | No — too slow, too many moving parts | + +`scripts/test-maintenance-cycle.ts` predates this framework and is correct for what it does. It is +not being ported or absorbed into Vitest. It was only renamed, from `test-maintenance-cycle` to +`e2e:maintenance`, so that `npm test` unambiguously means "fast, hermetic, no network". + +## Layout + +``` +tests/ +├── db/ tests that need a database +├── fixtures/ captured Bungie PGCR JSON + README +├── helpers/ builders, seeding, db access +└── setup/ global setup: db path, network guard +src/**/*.test.ts pure-logic tests, next to what they cover +``` + +Colocated or under `tests/`? Colocate when the test needs nothing but the module — it moves with +the code it covers. Put it under `tests/` when it needs a database, a fixture, or a helper. + +## The two ground rules + +**Never mock our own modules.** `vi.mock('@/lib/db/queries')` tests the mock. The database is real +— `better-sqlite3` opens one in about a millisecond, faster than the mock scaffolding it replaces, +and it validates the actual SQL. See [ADR 0004](../docs/adr/0004-mock-only-at-the-network-boundary.md). + +**Mock `fetch`, and only `fetch`.** `tests/setup/no-network.ts` replaces it with a thrower before +every test, so a test reaching the real internet fails loudly rather than quietly burning Bungie +API quota. Stub it deliberately when you need a response: + +```ts +vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"ErrorCode":1}'))); +``` + +No cleanup needed — the guard re-arms itself before the next test. + +## Adding a test + +**Pure logic** — colocate it, import directly, done: + +```ts +// src/lib/crawler/pgcr.test.ts +import { processPGCR } from './pgcr'; +import { buildPGCR, buildFireteam } from '../../../tests/helpers/pgcr-builder'; + +it('counts the run as completed when a single member finished', () => { + const pgcr = buildPGCR({ entries: buildFireteam({ size: 6, completions: 1 }) }); + expect(processPGCR(pgcr).completed).toBe(true); +}); +``` + +**Anything touching the database** — reset in `beforeEach`, seed through the helpers: + +```ts +// tests/db/whatever.test.ts +import { resetTestDb } from '../helpers/db'; +import { seedRun, seedPlayer, hoursAgo } from '../helpers/seed'; + +beforeEach(() => { resetTestDb(); }); + +it('excludes a checkpoint run', () => { + seedRun({ instanceId: '1', completedBy: ['p1'], startedFromBeginning: false }); + expect(runLeaderboardRows(24, [], 10)).toEqual([]); +}); +``` + +Each test file gets its own throwaway database in a temp directory, created with the production +schema. You don't have to set it up — `tests/setup/test-db-path.ts` handles it before your imports +run. A real file rather than `:memory:` for a specific reason: +[ADR 0003](../docs/adr/0003-tests-run-against-a-real-sqlite-file.md). + +`seedRun` goes through `insertFullPGCR`, the same chokepoint all four production ingestion sources +use — so seeded rows are rows production could actually create. Don't reach for raw `INSERT`s. + +**Fixtures or builders?** Fixtures when the point is "this is what Bungie really sends". Builders +when you need to vary one field across cases. See [fixtures/README.md](./fixtures/README.md). + +## Writing them + +Name a test as a claim about behaviour, not a description of code. `excludes a run that nobody +completed` beats `test filter logic`. When you read a failure at 2am, the name is what you get. + +Comment the *why* when it isn't obvious from the name — especially when a test pins behaviour +that's wrong but current. Those are labelled `BUG:` and say so in the comment, so nobody "fixes" +the test instead of the code. diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..9e4c1d7 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,55 @@ +# PGCR fixtures + +Real Bungie PGCR responses, captured verbatim. PGCR data is public, so these are committed as-is. + +## Why captured, not hand-written + +A hand-authored PGCR encodes what we *believe* the API returns. These fixtures exist to check that +belief, so writing them ourselves would defeat the point. Bungie's real responses carry quirks we +would not think to invent: `startingPhaseIndex` absent entirely, entry counts above six, activity +durations that disagree with every player's reported time. + +Use a fixture when the point is "this is what Bungie really sends". Use a builder from +`../helpers/pgcr-builder.ts` when you need to vary one field across several cases. Fixtures for +realism, builders for permutation. + +## Capturing + +```bash +npm run capture-fixtures +``` + +Reads `BUNGIE_API_KEY` from `.env` and writes every fixture below. Re-running overwrites them. + +The script prints the salient fields for each capture — entry count, completion count, +`fromBeginning`, duration versus longest time played, missing names — so a fixture that no longer +demonstrates its stated case is visible rather than silently wrong. Bungie does occasionally stop +serving old PGCRs; if a capture starts failing, pick a replacement instance and update +`scripts/capture-pgcr-fixture.ts`. + +Every instance ID was chosen by querying the production database for runs that actually exhibit +the property in question, so each is a real observed run rather than a hypothetical. + +## The fixtures + +| File | Instance | What makes it interesting | +|---|---|---| +| `pgcr-fullclear-salvations-edge.json` | 17091392013 | Baseline. Six players, started from the beginning, completed. | +| `pgcr-checkpoint-root-of-nightmares.json` | 17091462346 | `activityWasStartedFromBeginning` is false — a checkpoint run, which every leaderboard must exclude. | +| `pgcr-zero-completions-vault-of-glass.json` | 17091467640 | No entry has `completed = 1`. The most common shape in the table: 55% of stored PGCRs. | +| `pgcr-partial-completion-last-wish.json` | 17091283535 | Some finished, some didn't. `completed` is per-entry and ANY completion counts the run. | +| `pgcr-duration-divergence-garden.json` | 17091200569 | Activity duration (~2069s) far exceeds the longest per-player time (~981s). Tier 1 vs Tier 2 derivation diverge. | +| `pgcr-no-duration-crotas-end.json` | 17091316490 | Stored with a NULL `ended_at` — no duration was derivable. Exercises the Tier 3 fallback. | +| `pgcr-missing-bungie-name.json` | 16975643976 | At least one entry lacks `bungieGlobalDisplayName`. Player extraction must tolerate it. | +| `pgcr-non-raid.json` | probed | A non-raid activity, so `isRaidActivityHash` rejects it. Found by probing forward from a known raid instance until one isn't a raid. | + +None of these are synthetic. If a case ever becomes uncapturable and has to be hand-edited from a +real fixture, say so in this table with what was changed and why. + +## A case that cannot be captured + +The original brief asked for a checkpoint fixture identified by `startingPhaseIndex > 0`. **No such +PGCR exists to capture.** Bungie has stopped sending `startingPhaseIndex` — it is absent on all +827,076 rows in the production database. Checkpoint runs are identified by +`activityWasStartedFromBeginning` instead, which is what +`pgcr-checkpoint-root-of-nightmares.json` covers. See `docs/decisions.md`. From e6fd1b2fc604a292a21e371d665f210747f248a2 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:35:20 -0400 Subject: [PATCH 13/17] test: add captured PGCR fixtures and tests against them (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight real Bungie responses, captured verbatim. Capturing them corrected three claims that had been inferred from the database rather than observed at the source. The conclusions held; the mechanisms did not. - startingPhaseIndex is SENT, always 0 — not absent. Present on every fixture including three confirmed checkpoint runs. The database showed 0 everywhere because the writer coerces with `|| 0`, which had flattened the evidence. So isFullClear's `=== 0` branch fires, not its `=== undefined` branch. Still true for 100% of runs; the defect is unchanged. Corrected in the docs and test comments. - A six-entry raid report can belong to two players with three characters each. pgcr_players is keyed (instance_id, membership_id) with INSERT OR IGNORE, so only each player's first entry survives — 981s stored where their longest character played 1494s. Those columns are read nowhere in src/, so it is latent. Duration derivation is unaffected: it runs before the dedupe. - Bungie can withhold identity entirely: nineteen entries, all isPublic false with membershipType 0 and no name field at all. Not a missing global name with a platform fallback — no fallback exists. Ingestion stores all nineteen with NULL names, which is correct. Two fixtures renamed to what they actually are: the "duration divergence" case is really the multi-character case (the gap is 8%), and the "no duration" case is really an absurd 27384s duration on an 18-minute run. There is no true Tier 3 fixture; builders cover it. --- docs/decisions.md | 38 +- docs/testing-framework-plan.md | 8 +- scripts/capture-pgcr-fixture.ts | 8 +- src/lib/crawler/pgcr.test.ts | 8 +- tests/db/full-clear-flag.test.ts | 12 +- tests/fixtures/README.md | 70 +- .../pgcr-absurd-duration-crotas-end.json | 786 ++++ .../pgcr-checkpoint-root-of-nightmares.json | 1490 +++++++ .../pgcr-fullclear-salvations-edge.json | 2031 +++++++++ tests/fixtures/pgcr-missing-bungie-name.json | 3764 +++++++++++++++++ .../fixtures/pgcr-multi-character-garden.json | 1120 +++++ tests/fixtures/pgcr-non-raid.json | 906 ++++ .../pgcr-partial-completion-last-wish.json | 377 ++ .../pgcr-zero-completions-vault-of-glass.json | 1683 ++++++++ tests/helpers/pgcr-builder.ts | 5 +- tests/helpers/seed.ts | 49 +- tests/real-pgcrs.test.ts | 232 + 17 files changed, 12535 insertions(+), 52 deletions(-) create mode 100644 tests/fixtures/pgcr-absurd-duration-crotas-end.json create mode 100644 tests/fixtures/pgcr-checkpoint-root-of-nightmares.json create mode 100644 tests/fixtures/pgcr-fullclear-salvations-edge.json create mode 100644 tests/fixtures/pgcr-missing-bungie-name.json create mode 100644 tests/fixtures/pgcr-multi-character-garden.json create mode 100644 tests/fixtures/pgcr-non-raid.json create mode 100644 tests/fixtures/pgcr-partial-completion-last-wish.json create mode 100644 tests/fixtures/pgcr-zero-completions-vault-of-glass.json create mode 100644 tests/real-pgcrs.test.ts diff --git a/docs/decisions.md b/docs/decisions.md index 8d96d60..7c09c4f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -157,9 +157,10 @@ way that were **not** fixed. `fetchAndStorePGCR` persists Bungie's raw `activityWasStartedFromBeginning`, and every leaderboard filters on that column (`leaderboard-cache.ts:175`, `queries.ts:760/781/820/855`). -It is also incorrect. Bungie has stopped sending `startingPhaseIndex` — it is `0` on **all** -827,076 stored rows, meaning absent — so the `startingPhaseIndex === undefined` branch fires -unconditionally and the field is `true` for 100% of runs. Of those, 568,648 have +It is also incorrect. Bungie now reports `startingPhaseIndex: 0` on every PGCR — verified against +live API captures, including confirmed checkpoint runs where `activityWasStartedFromBeginning` is +`false`. The field is present but inert. So the `startingPhaseIndex === 0` branch fires +unconditionally and `isFullClear` is `true` for 100% of runs. Of those, 568,648 have `activity_was_started_from_beginning = 0`, i.e. they are checkpoint runs. Wiring this field into the writer would inflate every full-clear leaderboard by roughly 2.2×. @@ -205,3 +206,34 @@ is where a wrong row set would now come from. 456,009 of 827,076 stored PGCRs (55%) have no player with `completed = 1`. Not a defect, but worth knowing before reasoning about any query that joins through completions: the "no completions" case is the common path, not an edge case. + +### Addendum, same day — what capturing real fixtures corrected + +Three claims above were inferred from the database and turned out to be wrong at the source. The +conclusions held; the mechanisms did not. + +**`startingPhaseIndex` is sent, and is always `0`.** Not absent, as first written. It is present on +every captured PGCR including three confirmed checkpoint runs +(`activityWasStartedFromBeginning: false`). The database showed `0` everywhere because the writer +coerces with `|| 0`, which had flattened the evidence. So `isFullClear`'s `=== 0` branch fires +rather than its `=== undefined` branch — the field is still `true` for 100% of runs, so nothing +about the defect changes. + +**A player can appear several times in one report.** `pgcr-multi-character-garden.json` has six +entries belonging to **two** people, three characters each. `pgcr_players` is keyed +`(instance_id, membership_id)` with `INSERT OR IGNORE`, so only each player's *first* entry is +stored: that player's `time_played_seconds` records 981s when their longest character played 1494s. +`kills`, `deaths`, `assists` and `time_played_seconds` are written but **read nowhere in `src/`**, +so this is latent rather than user-visible. Duration derivation is unaffected — it runs on the +in-memory entries before the dedupe. + +**Bungie can withhold identity entirely.** `pgcr-missing-bungie-name.json` has nineteen entries, +every one arriving as `isPublic: false`, `membershipType: 0`, with **no `displayName` and no +`bungieGlobalDisplayName`**. This is not "the global name is missing so fall back to the platform +name" — there is no fallback left, and `formatDisplayName` ends up rendering a raw membership id. +Ingestion handles it correctly: all nineteen rows store with NULL names rather than being rejected, +which is right, since the run itself is real. + +Note also that `types.ts` declares `UserInfoCard.displayName` and +`DestinyPostGameCarnageReportData.startingPhaseIndex` as required, and both are optional in +practice. The type is more confident than the API. diff --git a/docs/testing-framework-plan.md b/docs/testing-framework-plan.md index 02de141..be6e0c0 100644 --- a/docs/testing-framework-plan.md +++ b/docs/testing-framework-plan.md @@ -45,9 +45,11 @@ wrong. Across 827,076 rows: | `activity_was_started_from_beginning` | `0` → 568,648 · `1` → 258,426 | | `completed` | `0` → 456,009 · `1` → 371,070 | -Bungie has stopped sending `startingPhaseIndex` entirely. So the `startingPhaseIndex === undefined` -branch fires unconditionally and `isFullClear` is `true` for **100%** of runs — including the -568,648 that are genuinely not full clears. It is inert only because nothing consumes it. Wired +Bungie now reports `startingPhaseIndex: 0` on every PGCR. Confirmed against live captures in +`tests/fixtures/`, including checkpoint runs where `activityWasStartedFromBeginning` is `false` — +the field is present but no longer discriminates anything. So the `startingPhaseIndex === 0` branch +fires unconditionally and `isFullClear` is `true` for **100%** of runs, including the 568,648 that +are genuinely not full clears. It is inert only because nothing consumes it. Wired up, it would inflate every leaderboard by roughly 2.2×. Consequence for fixtures: the brief's requested "checkpoint run (`startingPhaseIndex > 0`)" diff --git a/scripts/capture-pgcr-fixture.ts b/scripts/capture-pgcr-fixture.ts index fa02fb0..bf58903 100644 --- a/scripts/capture-pgcr-fixture.ts +++ b/scripts/capture-pgcr-fixture.ts @@ -51,14 +51,14 @@ const TARGETS: Target[] = [ why: 'Some entries completed, some did not. `completed` is per-entry; ANY completion counts.', }, { - file: 'pgcr-duration-divergence-garden.json', + file: 'pgcr-multi-character-garden.json', instanceId: '17091200569', - why: 'Activity duration (~2069s) far exceeds the longest per-player time (~981s). Tier 1 vs Tier 2 divergence.', + why: 'Six entries but only two distinct players — three characters each. Also a mild Tier 1 vs Tier 2 duration gap (2069s vs 2037s).', }, { - file: 'pgcr-no-duration-crotas-end.json', + file: 'pgcr-absurd-duration-crotas-end.json', instanceId: '17091316490', - why: 'Stored with a NULL ended_at, so no usable duration was derivable. Exercises the Tier 3 fallback.', + why: 'Reports a 27384s (7.6h) activity duration for a run where nobody played past 1093s. The megalobby corruption that FUTURE_ENDED_SKEW_SECONDS exists to reject.', }, { file: 'pgcr-missing-bungie-name.json', diff --git a/src/lib/crawler/pgcr.test.ts b/src/lib/crawler/pgcr.test.ts index 6e801c7..cdad575 100644 --- a/src/lib/crawler/pgcr.test.ts +++ b/src/lib/crawler/pgcr.test.ts @@ -15,10 +15,10 @@ import { * NOT COVERED HERE, DELIBERATELY: `ProcessedPGCR.isFullClear`. That field is * computed and returned but never read by anything — `fetchAndStorePGCR` * persists Bungie's raw `activityWasStartedFromBeginning` instead, and every - * leaderboard filters on that column. The derivation is also wrong: Bungie no - * longer sends `startingPhaseIndex` (absent on all 827k stored rows), so the - * `=== undefined` branch fires unconditionally and reports every run as a full - * clear. It is dead code slated for removal, so pinning its behaviour here would + * leaderboard filters on that column. The derivation is also wrong: Bungie now + * reports `startingPhaseIndex: 0` on every PGCR — including checkpoint runs, as + * the captured fixtures show — so the `=== 0` branch fires unconditionally and + * reports every run as a full clear. It is dead code slated for removal, so pinning its behaviour here would * only make that removal harder. The signal that actually decides leaderboard * membership is covered in tests/db/full-clear-flag.test.ts. * See docs/decisions.md. diff --git a/tests/db/full-clear-flag.test.ts b/tests/db/full-clear-flag.test.ts index 450212d..85fb0aa 100644 --- a/tests/db/full-clear-flag.test.ts +++ b/tests/db/full-clear-flag.test.ts @@ -11,10 +11,10 @@ import { readPgcrRow, seedRun } from '../helpers/seed'; * pgcrs.activity_was_started_from_beginning persisted, and what every * leaderboard filters on * - * The first is dead code. Bungie stopped sending `startingPhaseIndex` — it is - * absent on all 827k stored rows — so `isFullClear`'s `=== undefined` branch - * fires unconditionally and reports every run as a full clear, including the - * 568k that are checkpoint runs. Nothing reads it, so nothing breaks today; + * The first is dead code. Bungie reports `startingPhaseIndex: 0` on every PGCR, + * including confirmed checkpoint runs (see tests/real-pgcrs.test.ts), so + * `isFullClear`'s `=== 0` branch fires unconditionally and reports every run as + * a full clear — including the 568k that are checkpoint runs. Nothing reads it, so nothing breaks today; * wiring it up would inflate every leaderboard by roughly 2.2x. * * These tests pin the signal that ships, so that if anyone ever "tidies" the @@ -43,8 +43,8 @@ describe('the persisted full-clear flag', () => { }); it('stores a zero starting phase index regardless of the run type', () => { - // Bungie no longer sends startingPhaseIndex and the writer coerces it with - // `|| 0`, so the column is 0 for every row in production. Pinned so nobody + // Bungie reports startingPhaseIndex as 0 for every run and the writer coerces + // it with `|| 0` anyway, so the column is 0 for every row in production. Pinned so nobody // writes a query that assumes this column still discriminates anything. seedRun({ instanceId: '1', completedBy: ['p1'], startedFromBeginning: true }); seedRun({ instanceId: '2', completedBy: ['p1'], startedFromBeginning: false }); diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 9e4c1d7..3614fc0 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,13 +1,20 @@ # PGCR fixtures -Real Bungie PGCR responses, captured verbatim. PGCR data is public, so these are committed as-is. +Real Bungie PGCR responses, captured verbatim on 2026-07-26. PGCR data is public, so these are +committed as-is. None are synthetic. ## Why captured, not hand-written A hand-authored PGCR encodes what we *believe* the API returns. These fixtures exist to check that -belief, so writing them ourselves would defeat the point. Bungie's real responses carry quirks we -would not think to invent: `startingPhaseIndex` absent entirely, entry counts above six, activity -durations that disagree with every player's reported time. +belief, so writing them ourselves would defeat the point. + +That is not a theoretical concern. Capturing these corrected three things we had wrong, each +inferred from the database rather than observed at the source: + +- `startingPhaseIndex` is **present and always `0`**, including on confirmed checkpoint runs. We + had assumed Bungie stopped sending it, because the writer's `|| 0` had flattened the evidence. +- A "six player" raid report can belong to **two people** with three characters each. +- "No derivable duration" was really **an absurd duration** — 27384s reported for an 18-minute run. Use a fixture when the point is "this is what Bungie really sends". Use a builder from `../helpers/pgcr-builder.ts` when you need to vary one field across several cases. Fixtures for @@ -19,37 +26,44 @@ realism, builders for permutation. npm run capture-fixtures ``` -Reads `BUNGIE_API_KEY` from `.env` and writes every fixture below. Re-running overwrites them. +Reads `BUNGIE_API_KEY` from `.env` and rewrites every fixture below. The script prints the salient fields for each capture — entry count, completion count, `fromBeginning`, duration versus longest time played, missing names — so a fixture that no longer -demonstrates its stated case is visible rather than silently wrong. Bungie does occasionally stop -serving old PGCRs; if a capture starts failing, pick a replacement instance and update -`scripts/capture-pgcr-fixture.ts`. +demonstrates its stated case is visible rather than silently wrong. Check that output against the +table below whenever you re-capture. Bungie does eventually stop serving old PGCRs; if a capture +starts failing, pick a replacement instance and update `scripts/capture-pgcr-fixture.ts`. -Every instance ID was chosen by querying the production database for runs that actually exhibit -the property in question, so each is a real observed run rather than a hypothetical. +Every instance ID was chosen by querying the production database for runs exhibiting the property +in question, so each is a real observed run rather than a hypothetical. ## The fixtures | File | Instance | What makes it interesting | |---|---|---| -| `pgcr-fullclear-salvations-edge.json` | 17091392013 | Baseline. Six players, started from the beginning, completed. | -| `pgcr-checkpoint-root-of-nightmares.json` | 17091462346 | `activityWasStartedFromBeginning` is false — a checkpoint run, which every leaderboard must exclude. | +| `pgcr-fullclear-salvations-edge.json` | 17091392013 | Baseline. Six players, six completions, started from the beginning. | +| `pgcr-checkpoint-root-of-nightmares.json` | 17091462346 | `activityWasStartedFromBeginning: false` — a checkpoint run, which every leaderboard must exclude. Seven entries. | | `pgcr-zero-completions-vault-of-glass.json` | 17091467640 | No entry has `completed = 1`. The most common shape in the table: 55% of stored PGCRs. | -| `pgcr-partial-completion-last-wish.json` | 17091283535 | Some finished, some didn't. `completed` is per-entry and ANY completion counts the run. | -| `pgcr-duration-divergence-garden.json` | 17091200569 | Activity duration (~2069s) far exceeds the longest per-player time (~981s). Tier 1 vs Tier 2 derivation diverge. | -| `pgcr-no-duration-crotas-end.json` | 17091316490 | Stored with a NULL `ended_at` — no duration was derivable. Exercises the Tier 3 fallback. | -| `pgcr-missing-bungie-name.json` | 16975643976 | At least one entry lacks `bungieGlobalDisplayName`. Player extraction must tolerate it. | -| `pgcr-non-raid.json` | probed | A non-raid activity, so `isRaidActivityHash` rejects it. Found by probing forward from a known raid instance until one isn't a raid. | - -None of these are synthetic. If a case ever becomes uncapturable and has to be hand-edited from a -real fixture, say so in this table with what was changed and why. - -## A case that cannot be captured - -The original brief asked for a checkpoint fixture identified by `startingPhaseIndex > 0`. **No such -PGCR exists to capture.** Bungie has stopped sending `startingPhaseIndex` — it is absent on all -827,076 rows in the production database. Checkpoint runs are identified by -`activityWasStartedFromBeginning` instead, which is what -`pgcr-checkpoint-root-of-nightmares.json` covers. See `docs/decisions.md`. +| `pgcr-partial-completion-last-wish.json` | 17091283535 | Two entries, one completion. `completed` is per-entry and ANY completion counts the run. | +| `pgcr-multi-character-garden.json` | 17091200569 | **Six entries, two distinct players** — three characters each. Also a mild Tier 1 vs Tier 2 duration gap (2069s reported, 2037s derivable). | +| `pgcr-absurd-duration-crotas-end.json` | 17091316490 | Reports a **27384s (7.6 hour)** duration for a run where nobody played past 1093s. The megalobby corruption `FUTURE_ENDED_SKEW_SECONDS` exists to reject. | +| `pgcr-missing-bungie-name.json` | 16975643976 | **Nineteen entries, every one anonymous** — `isPublic: false`, `membershipType: 0`, and no name field of any kind. Not "the global name is missing", but no identity at all. | +| `pgcr-non-raid.json` | 17091392014 | A non-raid activity, so `isRaidActivityHash` rejects it. Found by probing forward from a known raid instance. | + +## Two cases with no fixture + +**A checkpoint run identified by `startingPhaseIndex > 0`.** The original brief asked for this. +No such PGCR exists to capture — the field is `0` on every one of the 827,076 rows in production +*and* on every fixture above, including the three that are genuinely checkpoint runs. Checkpoint +runs are identified by `activityWasStartedFromBeginning` instead, which +`pgcr-checkpoint-root-of-nightmares.json` covers. + +**A true Tier 3 run, where no duration is derivable at all.** Every captured PGCR reports a usable +`activityDurationSeconds`. `pgcr-absurd-duration-crotas-end.json` was originally captured expecting +this case — its database row has a NULL `ended_at` — but the NULL came from the future-end-time +guard rejecting a corrupt duration, not from Tier 3. Tier 3 is covered by builders in +`tests/db/ended-at-derivation.test.ts`. + +Note that the absurd-duration fixture's NULL-ness is **time dependent**: the guard compares against +the ingest clock, so re-seeding that run today produces a non-NULL `ended_at`. Do not write a test +asserting NULL from that fixture — use a builder, which controls the period explicitly. diff --git a/tests/fixtures/pgcr-absurd-duration-crotas-end.json b/tests/fixtures/pgcr-absurd-duration-crotas-end.json new file mode 100644 index 0000000..3398b6a --- /dev/null +++ b/tests/fixtures/pgcr-absurd-duration-crotas-end.json @@ -0,0 +1,786 @@ +{ + "period": "2026-07-26T20:20:25Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": true, + "activityDifficultyTier": -1, + "activityDetails": { + "referenceId": 1507509200, + "directorActivityHash": 1507509200, + "instanceId": "17091316490", + "mode": 4, + "modes": [ + 7, + 4 + ], + "isPrivate": false, + "membershipType": 0 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/d914fab82fe3f1d6f751627e04338f51.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018457300313", + "displayName": "chingmugga", + "bungieGlobalDisplayName": "chingmugga", + "bungieGlobalDisplayNameCode": 1299 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 1607, + "emblemHash": 1907674138 + }, + "characterId": "2305843009535885726", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 27384, + "displayValue": "7h 36m" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 0, + "displayValue": "" + } + }, + "startSeconds": { + "basic": { + "value": 199, + "displayValue": "3m 19s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 27, + "displayValue": "0m 27s" + } + }, + "playerCount": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/97d1647338c17bdddc79bfac4ed01519.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 3, + 6, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018527901876", + "displayName": "Lutchet", + "bungieGlobalDisplayName": "Lutchet", + "bungieGlobalDisplayNameCode": 5129 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 3887404748, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 4132147348 + }, + "characterId": "2305843010300344547", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 27384, + "displayValue": "7h 36m" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 8250665239436292000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1093, + "displayValue": "18m 13s" + } + }, + "playerCount": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/d0d8a24c8a8143747adb192caaa43a5f.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 3, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018491329287", + "displayName": "RedsWinter", + "bungieGlobalDisplayName": "Red", + "bungieGlobalDisplayNameCode": 7227 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 383734238 + }, + "characterId": "2305843010331984142", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "opponentsDefeated": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "efficiency": { + "basic": { + "value": 1.5, + "displayValue": "1.50" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 1.5, + "displayValue": "1.50" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 1.5, + "displayValue": "1.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 27384, + "displayValue": "7h 36m" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 8250665239436292000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 3, + "displayValue": "0m 3s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 507, + "displayValue": "8m 27s" + } + }, + "playerCount": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 1280894514, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "33%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/775aba88c3235c8a38d68ec69f1f810c.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018538347503", + "displayName": "tana", + "bungieGlobalDisplayName": "Enchant", + "bungieGlobalDisplayNameCode": 4152 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 3903070392 + }, + "characterId": "2305843010785034770", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 206, + "displayValue": "206" + } + }, + "opponentsDefeated": { + "basic": { + "value": 206, + "displayValue": "206" + } + }, + "efficiency": { + "basic": { + "value": 206, + "displayValue": "206.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 206, + "displayValue": "206.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 206, + "displayValue": "206.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 27384, + "displayValue": "7h 36m" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 8250665239436292000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 3, + "displayValue": "0m 3s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 516, + "displayValue": "8m 36s" + } + }, + "playerCount": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3293207827, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 43, + "displayValue": "43" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.09302325581395349, + "displayValue": "9%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 161, + "displayValue": "161" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 9, + "displayValue": "9" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/fixtures/pgcr-checkpoint-root-of-nightmares.json b/tests/fixtures/pgcr-checkpoint-root-of-nightmares.json new file mode 100644 index 0000000..4e7d04e --- /dev/null +++ b/tests/fixtures/pgcr-checkpoint-root-of-nightmares.json @@ -0,0 +1,1490 @@ +{ + "period": "2026-07-26T21:22:51Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": false, + "activityDifficultyTier": -1, + "activityDetails": { + "referenceId": 2381413764, + "directorActivityHash": 2381413764, + "instanceId": "17091462346", + "mode": 4, + "modes": [ + 7, + 4 + ], + "isPrivate": false, + "membershipType": 0 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/bebd5209420449f9036e731b4a65f361.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 2, + 3, + 6, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018430340724", + "displayName": "MBlack475", + "bungieGlobalDisplayName": "MBlack475", + "bungieGlobalDisplayNameCode": 5321 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 165005424 + }, + "characterId": "2305843009262976722", + "values": { + "assists": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "opponentsDefeated": { + "basic": { + "value": 34, + "displayValue": "34" + } + }, + "efficiency": { + "basic": { + "value": 34, + "displayValue": "34.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 27, + "displayValue": "27.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 30.5, + "displayValue": "30.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 291, + "displayValue": "4m 51s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -708723304309311500, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 3, + "displayValue": "0m 3s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 288, + "displayValue": "4m 48s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 1471212226, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 14, + "displayValue": "14" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.07142857142857142, + "displayValue": "7%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/9d0b71aea62aa6ab003d095f52c64872.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018517087279", + "displayName": "Armogeddon", + "bungieGlobalDisplayName": "Armogeddon", + "bungieGlobalDisplayNameCode": 6928 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 3228576704 + }, + "characterId": "2305843009871374016", + "values": { + "assists": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "efficiency": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "0.33" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0.16666666666666666, + "displayValue": "0.17" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 291, + "displayValue": "4m 51s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -8931696098840895000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 289, + "displayValue": "4m 49s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/4b4f1ab72cf309112748faa6940e8a41.jpg", + "crossSaveOverride": 3, + "applicableMembershipTypes": [ + 1, + 6, + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018497795565", + "displayName": "sammers", + "bungieGlobalDisplayName": "copycat", + "bungieGlobalDisplayNameCode": 8146 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 3887404748, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 3800278197 + }, + "characterId": "2305843010074994078", + "values": { + "assists": { + "basic": { + "value": 12, + "displayValue": "12" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "kills": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "opponentsDefeated": { + "basic": { + "value": 39, + "displayValue": "39" + } + }, + "efficiency": { + "basic": { + "value": 9.75, + "displayValue": "9.75" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 6.75, + "displayValue": "6.75" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 8.25, + "displayValue": "8.25" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 291, + "displayValue": "4m 51s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -708723304309311500, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 4, + "displayValue": "0m 4s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 287, + "displayValue": "4m 47s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 1715391576, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 23, + "displayValue": "23" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.08695652173913043, + "displayValue": "9%" + } + } + } + }, + { + "referenceId": 1802315656, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2591746970, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/9f121959eafcbbef4796bdea398f8e48.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018527934833", + "displayName": "Vick XCII", + "bungieGlobalDisplayName": "Vick XCII", + "bungieGlobalDisplayNameCode": 130 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 3887404748, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 908153540 + }, + "characterId": "2305843010606614390", + "values": { + "assists": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "opponentsDefeated": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "efficiency": { + "basic": { + "value": 2.5, + "displayValue": "2.50" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 2, + "displayValue": "2.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 2.25, + "displayValue": "2.25" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 291, + "displayValue": "4m 51s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -708723304309311500, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 54, + "displayValue": "0m 54s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 237, + "displayValue": "3m 57s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3418719964, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/4dff669ed6d7273fa751997d09cf2525.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 3, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018471369112", + "displayName": "cheng_hang_low", + "bungieGlobalDisplayName": "Chaz", + "bungieGlobalDisplayNameCode": 8734 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 1784442048 + }, + "characterId": "2305843010641374831", + "values": { + "assists": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 15, + "displayValue": "15" + } + }, + "opponentsDefeated": { + "basic": { + "value": 20, + "displayValue": "20" + } + }, + "efficiency": { + "basic": { + "value": 20, + "displayValue": "20.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 15, + "displayValue": "15.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 17.5, + "displayValue": "17.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 291, + "displayValue": "4m 51s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -708723304309311500, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 3, + "displayValue": "0m 3s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 288, + "displayValue": "4m 48s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 4174431791, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 15, + "displayValue": "15" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.4666666666666667, + "displayValue": "47%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/31d60aedc5ebdd94c369d1b4f352cf45.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018527934833", + "displayName": "Vick XCII", + "bungieGlobalDisplayName": "Vick XCII", + "bungieGlobalDisplayNameCode": 130 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 526, + "emblemHash": 3508476927 + }, + "characterId": "2305843010783094914", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 291, + "displayValue": "4m 51s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -708723304309311500, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 24, + "displayValue": "0m 24s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/24e9133c9cc157853762de5a2c3853aa.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018557234021", + "displayName": "DICEMAN", + "bungieGlobalDisplayName": "DICEMAN", + "bungieGlobalDisplayNameCode": 4465 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 406, + "emblemHash": 1907674137 + }, + "characterId": "2305843010783414340", + "values": { + "assists": { + "basic": { + "value": 11, + "displayValue": "11" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 20, + "displayValue": "20" + } + }, + "opponentsDefeated": { + "basic": { + "value": 31, + "displayValue": "31" + } + }, + "efficiency": { + "basic": { + "value": 15.5, + "displayValue": "15.50" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 10, + "displayValue": "10.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 12.75, + "displayValue": "12.75" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 291, + "displayValue": "4m 51s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -708723304309311500, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 289, + "displayValue": "4m 49s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 1863583117, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 15, + "displayValue": "15" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2, + "displayValue": "20%" + } + } + } + }, + { + "referenceId": 3285784871, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/fixtures/pgcr-fullclear-salvations-edge.json b/tests/fixtures/pgcr-fullclear-salvations-edge.json new file mode 100644 index 0000000..5f86471 --- /dev/null +++ b/tests/fixtures/pgcr-fullclear-salvations-edge.json @@ -0,0 +1,2031 @@ +{ + "period": "2026-07-26T20:43:06Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": true, + "activityDifficultyTier": -1, + "activityDetails": { + "referenceId": 1541433876, + "directorActivityHash": 1541433876, + "instanceId": "17091392013", + "mode": 4, + "modes": [ + 7, + 4 + ], + "isPrivate": false, + "membershipType": 0 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/6bc9bafcd714a0f5501554b740ba497c.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 3, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018445419097", + "displayName": "Achryllic", + "bungieGlobalDisplayName": "War", + "bungieGlobalDisplayNameCode": 6141 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 3887404748, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 1576, + "emblemHash": 3228576714 + }, + "characterId": "2305843009311474238", + "values": { + "assists": { + "basic": { + "value": 33, + "displayValue": "33" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 226, + "displayValue": "226" + } + }, + "opponentsDefeated": { + "basic": { + "value": 259, + "displayValue": "259" + } + }, + "efficiency": { + "basic": { + "value": 129.5, + "displayValue": "129.50" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 113, + "displayValue": "113.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 121.25, + "displayValue": "121.25" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1246, + "displayValue": "20m 46s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -3445485178621350400, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1244, + "displayValue": "20m 44s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 73, + "displayValue": "73" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.0547945205479452, + "displayValue": "5%" + } + } + } + }, + { + "referenceId": 1111334348, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 16, + "displayValue": "16" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.25, + "displayValue": "25%" + } + } + } + }, + { + "referenceId": 393652859, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3460576091, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "100%" + } + } + } + }, + { + "referenceId": 2386208942, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 25, + "displayValue": "25" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.24, + "displayValue": "24%" + } + } + } + }, + { + "referenceId": 3211806999, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "100%" + } + } + } + }, + { + "referenceId": 2697143634, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 37, + "displayValue": "37" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.05405405405405406, + "displayValue": "5%" + } + } + } + }, + { + "referenceId": 4069880346, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 1085743380, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 22, + "displayValue": "22" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 11, + "displayValue": "11" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 28, + "displayValue": "28" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/bb345c3f323449ebb123569b41bb4738.jpg", + "crossSaveOverride": 3, + "applicableMembershipTypes": [ + 6, + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018468703758", + "displayName": "Azrael", + "bungieGlobalDisplayName": "Azrael", + "bungieGlobalDisplayNameCode": 7707 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 3887404748, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 2962546551 + }, + "characterId": "2305843009486464357", + "values": { + "assists": { + "basic": { + "value": 25, + "displayValue": "25" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "kills": { + "basic": { + "value": 140, + "displayValue": "140" + } + }, + "opponentsDefeated": { + "basic": { + "value": 165, + "displayValue": "165" + } + }, + "efficiency": { + "basic": { + "value": 33, + "displayValue": "33.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 28, + "displayValue": "28.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 30.5, + "displayValue": "30.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1246, + "displayValue": "20m 46s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -3445485178621350400, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1244, + "displayValue": "20m 44s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 66, + "displayValue": "66" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.030303030303030304, + "displayValue": "3%" + } + } + } + }, + { + "referenceId": 1111334348, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "100%" + } + } + } + }, + { + "referenceId": 1363886209, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 14, + "displayValue": "14" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 480368036, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 1085743380, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 1303313141, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2905188646, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 4049127142, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2, + "displayValue": "20%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 11, + "displayValue": "11" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 11, + "displayValue": "11" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/25c54e5c6474e6f7f9dc34ba9ea6daf4.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 3, + 6, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018495002284", + "displayName": "MCR7425", + "bungieGlobalDisplayName": "mc", + "bungieGlobalDisplayNameCode": 4377 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 898834093, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 3228576706 + }, + "characterId": "2305843009681024548", + "values": { + "assists": { + "basic": { + "value": 34, + "displayValue": "34" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "kills": { + "basic": { + "value": 152, + "displayValue": "152" + } + }, + "opponentsDefeated": { + "basic": { + "value": 186, + "displayValue": "186" + } + }, + "efficiency": { + "basic": { + "value": 62, + "displayValue": "62.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 50.666666666666664, + "displayValue": "50.67" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 56.333333333333336, + "displayValue": "56.33" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1246, + "displayValue": "20m 46s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -3445485178621350400, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1244, + "displayValue": "20m 44s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 62, + "displayValue": "62" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2905188646, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3157894736842105, + "displayValue": "32%" + } + } + } + }, + { + "referenceId": 1363886209, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2198166292, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2697143634, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 22, + "displayValue": "22" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.09090909090909091, + "displayValue": "9%" + } + } + } + }, + { + "referenceId": 393652859, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 15, + "displayValue": "15" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/128d5d5874db84e28da2ae1a5d27e385.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 3, + 6, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018516535925", + "displayName": "Drop_remax", + "bungieGlobalDisplayName": "remax 么", + "bungieGlobalDisplayNameCode": 5629 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 548, + "emblemHash": 2979324135 + }, + "characterId": "2305843009895274489", + "values": { + "assists": { + "basic": { + "value": 31, + "displayValue": "31" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "kills": { + "basic": { + "value": 109, + "displayValue": "109" + } + }, + "opponentsDefeated": { + "basic": { + "value": 140, + "displayValue": "140" + } + }, + "efficiency": { + "basic": { + "value": 17.5, + "displayValue": "17.50" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 13.625, + "displayValue": "13.63" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 15.5625, + "displayValue": "15.56" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1246, + "displayValue": "20m 46s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -3445485178621350400, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1244, + "displayValue": "20m 44s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 29, + "displayValue": "29" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.10344827586206896, + "displayValue": "10%" + } + } + } + }, + { + "referenceId": 3821409356, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3211624072, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.375, + "displayValue": "38%" + } + } + } + }, + { + "referenceId": 1363886209, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2697143634, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 29, + "displayValue": "29" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.10344827586206896, + "displayValue": "10%" + } + } + } + }, + { + "referenceId": 4049127142, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "33%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/2f77520720175fc8796152ae5d623404.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 1, + 3, + 6, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018440253889", + "displayName": "bianok219", + "bungieGlobalDisplayName": "Bionic™", + "bungieGlobalDisplayNameCode": 7184 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 165005427 + }, + "characterId": "2305843010004194199", + "values": { + "assists": { + "basic": { + "value": 22, + "displayValue": "22" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "kills": { + "basic": { + "value": 133, + "displayValue": "133" + } + }, + "opponentsDefeated": { + "basic": { + "value": 155, + "displayValue": "155" + } + }, + "efficiency": { + "basic": { + "value": 38.75, + "displayValue": "38.75" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 33.25, + "displayValue": "33.25" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 36, + "displayValue": "36.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1246, + "displayValue": "20m 46s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -3445485178621350400, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1244, + "displayValue": "20m 44s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 38, + "displayValue": "38" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.02631578947368421, + "displayValue": "3%" + } + } + } + }, + { + "referenceId": 2905188646, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 4207120603, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 44, + "displayValue": "44" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 1363886209, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2198166292, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3211806999, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "100%" + } + } + } + }, + { + "referenceId": 3245446311, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "100%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 15, + "displayValue": "15" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/19bcc057f9c9fb0bfedcfee2a3c0be16.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 6, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018556576248", + "displayName": "KirkActually697", + "bungieGlobalDisplayName": "ChrisActually", + "bungieGlobalDisplayNameCode": 8703 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 118, + "emblemHash": 4183788701 + }, + "characterId": "2305843010764464176", + "values": { + "assists": { + "basic": { + "value": 46, + "displayValue": "46" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "kills": { + "basic": { + "value": 74, + "displayValue": "74" + } + }, + "opponentsDefeated": { + "basic": { + "value": 120, + "displayValue": "120" + } + }, + "efficiency": { + "basic": { + "value": 20, + "displayValue": "20.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 12.333333333333334, + "displayValue": "12.33" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 16.166666666666668, + "displayValue": "16.17" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1246, + "displayValue": "20m 46s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": -3445485178621350400, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1246, + "displayValue": "20m 46s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 2697143634, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 48, + "displayValue": "48" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.08333333333333333, + "displayValue": "8%" + } + } + } + }, + { + "referenceId": 4069880346, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3623686757, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/fixtures/pgcr-missing-bungie-name.json b/tests/fixtures/pgcr-missing-bungie-name.json new file mode 100644 index 0000000..58b926a --- /dev/null +++ b/tests/fixtures/pgcr-missing-bungie-name.json @@ -0,0 +1,3764 @@ +{ + "period": "2026-06-26T15:11:41Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": false, + "activityDifficultyTier": 0, + "activityDetails": { + "referenceId": 1516551982, + "directorActivityHash": 1516551982, + "instanceId": "16975643976", + "mode": 4, + "modes": [ + 7, + 4 + ], + "isPrivate": false, + "membershipType": 0 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018430950974" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 690263481 + }, + "characterId": "2305843009261736687", + "values": { + "assists": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 21, + "displayValue": "21" + } + }, + "opponentsDefeated": { + "basic": { + "value": 30, + "displayValue": "30" + } + }, + "efficiency": { + "basic": { + "value": 15, + "displayValue": "15.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 10.5, + "displayValue": "10.50" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 12.75, + "displayValue": "12.75" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 376, + "displayValue": "6m 16s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 357, + "displayValue": "5m 57s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3407395594, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.5263157894736842, + "displayValue": "53%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018452637778" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 548, + "emblemHash": 3919847954 + }, + "characterId": "2305843009265760620", + "values": { + "assists": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 12, + "displayValue": "12" + } + }, + "opponentsDefeated": { + "basic": { + "value": 25, + "displayValue": "25" + } + }, + "efficiency": { + "basic": { + "value": 25, + "displayValue": "25.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 12, + "displayValue": "12.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 18.5, + "displayValue": "18.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 228, + "displayValue": "3m 48s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3647341740, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2, + "displayValue": "20%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018434018062" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 3888032083 + }, + "characterId": "2305843009269399822", + "values": { + "assists": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 11, + "displayValue": "11" + } + }, + "opponentsDefeated": { + "basic": { + "value": 17, + "displayValue": "17" + } + }, + "efficiency": { + "basic": { + "value": 8.5, + "displayValue": "8.50" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 5.5, + "displayValue": "5.50" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 7, + "displayValue": "7.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 456, + "displayValue": "7m 36s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 269, + "displayValue": "4m 29s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3326135421, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018428476796" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 546, + "emblemHash": 4178714189 + }, + "characterId": "2305843009271105030", + "values": { + "assists": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 16, + "displayValue": "16" + } + }, + "opponentsDefeated": { + "basic": { + "value": 18, + "displayValue": "18" + } + }, + "efficiency": { + "basic": { + "value": 18, + "displayValue": "18.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 16, + "displayValue": "16.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 17, + "displayValue": "17.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 1020, + "displayValue": "17m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 385, + "displayValue": "6m 25s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3413860063, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 16, + "displayValue": "16" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.1875, + "displayValue": "19%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018436710777" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 0, + "lightLevel": 0, + "emblemHash": 0 + }, + "characterId": "2305843009271719945", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 0, + "displayValue": "" + } + }, + "startSeconds": { + "basic": { + "value": 430, + "displayValue": "7m 10s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 12, + "displayValue": "0m 12s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018451197748" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 54004488 + }, + "characterId": "2305843009286289179", + "values": { + "assists": { + "basic": { + "value": 14, + "displayValue": "14" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "opponentsDefeated": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "efficiency": { + "basic": { + "value": 27, + "displayValue": "27.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 13, + "displayValue": "13.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 20, + "displayValue": "20.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 247, + "displayValue": "4m 7s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 859869931, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2857142857142857, + "displayValue": "29%" + } + } + } + }, + { + "referenceId": 4019651319, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018473875915" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 3888032083 + }, + "characterId": "2305843009325624769", + "values": { + "assists": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "opponentsDefeated": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "efficiency": { + "basic": { + "value": 8, + "displayValue": "8.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 4, + "displayValue": "4.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 6, + "displayValue": "6.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 219, + "displayValue": "3m 39s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 2111625436, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018486712448" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 0, + "emblemHash": 788073490 + }, + "characterId": "2305843009423796145", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "opponentsDefeated": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "efficiency": { + "basic": { + "value": 13, + "displayValue": "13.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 13, + "displayValue": "13.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 13, + "displayValue": "13.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 1110, + "displayValue": "18m 30s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 308, + "displayValue": "5m 8s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3961462214, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.25, + "displayValue": "25%" + } + } + } + }, + { + "referenceId": 3285784871, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.25, + "displayValue": "25%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018452495462" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 0, + "lightLevel": 0, + "emblemHash": 0 + }, + "characterId": "2305843009469094139", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 0, + "displayValue": "" + } + }, + "startSeconds": { + "basic": { + "value": 402, + "displayValue": "6m 42s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1, + "displayValue": "0m 1s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018488805347" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 298334056 + }, + "characterId": "2305843009489095266", + "values": { + "assists": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "opponentsDefeated": { + "basic": { + "value": 17, + "displayValue": "17" + } + }, + "efficiency": { + "basic": { + "value": 17, + "displayValue": "17.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 7, + "displayValue": "7.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 12, + "displayValue": "12.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 2, + "displayValue": "0m 2s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 206, + "displayValue": "3m 26s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 4289226715, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "33%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018499052863" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 1661191192 + }, + "characterId": "2305843009698594810", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 38, + "displayValue": "38" + } + }, + "opponentsDefeated": { + "basic": { + "value": 38, + "displayValue": "38" + } + }, + "efficiency": { + "basic": { + "value": 38, + "displayValue": "38.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 38, + "displayValue": "38.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 38, + "displayValue": "38.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 955, + "displayValue": "15m 55s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 352, + "displayValue": "5m 52s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 2140635451, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 26, + "displayValue": "26" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3725585710, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2298039571, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018494052601" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 707041059 + }, + "characterId": "2305843009891714279", + "values": { + "assists": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "opponentsDefeated": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "efficiency": { + "basic": { + "value": 6, + "displayValue": "6.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 3, + "displayValue": "3.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 4.5, + "displayValue": "4.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 961, + "displayValue": "16m 1s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 423, + "displayValue": "7m 3s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018439434003" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 3888032092 + }, + "characterId": "2305843010150764320", + "values": { + "assists": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 24, + "displayValue": "24" + } + }, + "opponentsDefeated": { + "basic": { + "value": 32, + "displayValue": "32" + } + }, + "efficiency": { + "basic": { + "value": 16, + "displayValue": "16.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 12, + "displayValue": "12.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 14, + "displayValue": "14.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 382, + "displayValue": "6m 22s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 353, + "displayValue": "5m 53s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3736001860, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 17, + "displayValue": "17" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3211624072, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "100%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018479746031" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 2770607178 + }, + "characterId": "2305843010152994261", + "values": { + "assists": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "opponentsDefeated": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "efficiency": { + "basic": { + "value": 6, + "displayValue": "6.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 3, + "displayValue": "3.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 4.5, + "displayValue": "4.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 1068, + "displayValue": "17m 48s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 332, + "displayValue": "5m 32s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 1041028434, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "33%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018436426616" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 548, + "emblemHash": 707041059 + }, + "characterId": "2305843010441474057", + "values": { + "assists": { + "basic": { + "value": 45, + "displayValue": "45" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "kills": { + "basic": { + "value": 50, + "displayValue": "50" + } + }, + "opponentsDefeated": { + "basic": { + "value": 95, + "displayValue": "95" + } + }, + "efficiency": { + "basic": { + "value": 15.833333333333334, + "displayValue": "15.83" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 8.333333333333334, + "displayValue": "8.33" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 12.083333333333334, + "displayValue": "12.08" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1702, + "displayValue": "28m 22s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 2708806099, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 28, + "displayValue": "28" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2857142857142857, + "displayValue": "29%" + } + } + } + }, + { + "referenceId": 2366022261, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "kills": { + "basic": { + "value": 12, + "displayValue": "12" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018532088071" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 298334061 + }, + "characterId": "2305843010531094103", + "values": { + "assists": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 17, + "displayValue": "17" + } + }, + "opponentsDefeated": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "efficiency": { + "basic": { + "value": 13.5, + "displayValue": "13.50" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 8.5, + "displayValue": "8.50" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 11, + "displayValue": "11.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 294, + "displayValue": "4m 54s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 415, + "displayValue": "6m 55s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 214545213, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 12, + "displayValue": "12" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018511194064" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 0, + "lightLevel": 0, + "emblemHash": 0 + }, + "characterId": "2305843010552655045", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 0, + "displayValue": "" + } + }, + "startSeconds": { + "basic": { + "value": 1030, + "displayValue": "17m 10s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 27, + "displayValue": "0m 27s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018554475758" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 3888032095 + }, + "characterId": "2305843010709554387", + "values": { + "assists": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "opponentsDefeated": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "efficiency": { + "basic": { + "value": 5, + "displayValue": "5.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 3, + "displayValue": "3.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 4, + "displayValue": "4.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 4, + "displayValue": "0m 4s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 209, + "displayValue": "3m 29s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3245446311, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "crossSaveOverride": 0, + "isPublic": false, + "membershipType": 0, + "membershipId": "4611686018463558704" + }, + "classHash": 0, + "raceHash": 0, + "genderHash": 0, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 3888032083 + }, + "characterId": "2305843010781834041", + "values": { + "assists": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "opponentsDefeated": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "efficiency": { + "basic": { + "value": 9, + "displayValue": "9.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 6, + "displayValue": "6.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 7.5, + "displayValue": "7.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1734, + "displayValue": "28m 54s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 3911111240297402000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 394, + "displayValue": "6m 34s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 306, + "displayValue": "5m 6s" + } + }, + "playerCount": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 193009988, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/fixtures/pgcr-multi-character-garden.json b/tests/fixtures/pgcr-multi-character-garden.json new file mode 100644 index 0000000..2face8f --- /dev/null +++ b/tests/fixtures/pgcr-multi-character-garden.json @@ -0,0 +1,1120 @@ +{ + "period": "2026-07-26T19:25:17Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": true, + "activityDifficultyTier": -1, + "activityDetails": { + "referenceId": 1042180643, + "directorActivityHash": 1042180643, + "instanceId": "17091200569", + "mode": 4, + "modes": [ + 7, + 4 + ], + "isPrivate": false, + "membershipType": 0 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/3d3f3a5b8a73880956d13f31ee544e3a.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018462874397", + "displayName": "MistaHates4s", + "bungieGlobalDisplayName": "Oryx's Taken Glue", + "bungieGlobalDisplayNameCode": 9062 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 479, + "emblemHash": 2026109716 + }, + "characterId": "2305843009283987028", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 2069, + "displayValue": "34m 29s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -4671784694761834000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 789, + "displayValue": "13m 9s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 981, + "displayValue": "16m 21s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/fb7cf21487dffd363ec02127c4993346.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018462874397", + "displayName": "MistaHates4s", + "bungieGlobalDisplayName": "Oryx's Taken Glue", + "bungieGlobalDisplayNameCode": 9062 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 3887404748, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 46275857 + }, + "characterId": "2305843009320728729", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 2069, + "displayValue": "34m 29s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -4671784694761834000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 543, + "displayValue": "9m 3s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1494, + "displayValue": "24m 54s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/da283135842de6983df5ab6c595ce428.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 3, + 6, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018473876278", + "displayName": "GiornoDrinkT", + "bungieGlobalDisplayName": "Savathun's Onlyfans", + "bungieGlobalDisplayNameCode": 2533 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 543, + "emblemHash": 787024997 + }, + "characterId": "2305843009325894788", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 2069, + "displayValue": "34m 29s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -4671784694761834000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 889, + "displayValue": "14m 49s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 557, + "displayValue": "9m 17s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/18dc6c427a49dc411027a22b413b8b7c.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 3, + 6, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018473876278", + "displayName": "GiornoDrinkT", + "bungieGlobalDisplayName": "Savathun's Onlyfans", + "bungieGlobalDisplayNameCode": 2533 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 475, + "emblemHash": 298334061 + }, + "characterId": "2305843009675195762", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 2069, + "displayValue": "34m 29s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -4671784694761834000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 684, + "displayValue": "11m 24s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 988, + "displayValue": "16m 28s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/4d07bd0a923964dd344ec238776460f1.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018462874397", + "displayName": "MistaHates4s", + "bungieGlobalDisplayName": "Oryx's Taken Glue", + "bungieGlobalDisplayNameCode": 9062 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 3887404748, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 451, + "emblemHash": 2565108496 + }, + "characterId": "2305843009678825697", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 2069, + "displayValue": "34m 29s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -4671784694761834000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 995, + "displayValue": "16m 35s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 549, + "displayValue": "9m 9s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/e270170e30415d608ea6a4901bd84495.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 3, + 6, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018473876278", + "displayName": "GiornoDrinkT", + "bungieGlobalDisplayName": "Savathun's Onlyfans", + "bungieGlobalDisplayNameCode": 2533 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 3887404748, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 538, + "emblemHash": 1230660640 + }, + "characterId": "2305843009918354004", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 25, + "displayValue": "25" + } + }, + "opponentsDefeated": { + "basic": { + "value": 25, + "displayValue": "25" + } + }, + "efficiency": { + "basic": { + "value": 25, + "displayValue": "25.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 25, + "displayValue": "25.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 25, + "displayValue": "25.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 2069, + "displayValue": "34m 29s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -4671784694761834000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1916, + "displayValue": "31m 56s" + } + }, + "playerCount": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 2965080304, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 12, + "displayValue": "12" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/fixtures/pgcr-non-raid.json b/tests/fixtures/pgcr-non-raid.json new file mode 100644 index 0000000..1d9a3b8 --- /dev/null +++ b/tests/fixtures/pgcr-non-raid.json @@ -0,0 +1,906 @@ +{ + "period": "2026-07-26T20:45:34Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": true, + "activityDifficultyTier": 9, + "selectedSkullHashes": [ + 3830595819, + 550488408, + 2468650119, + 510222748, + 295800916, + 1701682929, + 295800916, + 295800916, + 295800916, + 295800916, + 3105737563, + 1903043004, + 3591337835, + 1042708060, + 3076476604, + 4147307117, + 295800916, + 295800916, + 295800916, + 449004569, + 295800916, + 295800916, + 3830595819, + 550488408, + 2468650119, + 510222748 + ], + "activityDetails": { + "referenceId": 935938264, + "directorActivityHash": 935938264, + "instanceId": "17091392014", + "mode": 3, + "modes": [ + 7, + 3, + 18 + ], + "isPrivate": false, + "membershipType": 1 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 15909, + "displayValue": "15,909" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/2eb4d5248601cee5a31eeb4404413345.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 2, + 3, + 5, + 6, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018463396679", + "displayName": "Axell776", + "bungieGlobalDisplayName": "Axell776", + "bungieGlobalDisplayNameCode": 9816 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 707041070 + }, + "characterId": "2305843009267324081", + "values": { + "assists": { + "basic": { + "value": 24, + "displayValue": "24" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 72, + "displayValue": "72" + } + }, + "opponentsDefeated": { + "basic": { + "value": 96, + "displayValue": "96" + } + }, + "efficiency": { + "basic": { + "value": 96, + "displayValue": "96.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 72, + "displayValue": "72.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 84, + "displayValue": "84.00" + } + }, + "score": { + "basic": { + "value": 15909, + "displayValue": "15,909" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1063, + "displayValue": "17m 43s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": 7834403886865681000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1063, + "displayValue": "17m 43s" + } + }, + "playerCount": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "teamScore": { + "basic": { + "value": 10909, + "displayValue": "10,909" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 1018012078, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "33%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 30, + "displayValue": "30" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "assists": { + "basic": { + "value": 24, + "displayValue": "24" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 72, + "displayValue": "72" + } + }, + "score": { + "basic": { + "value": 15909.0673828125, + "displayValue": "15,909" + } + }, + "partial_score": { + "basic": { + "value": 10909.0673828125, + "displayValue": "10,909" + } + }, + "display_total_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "display_team_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "display_time_bonus_points": { + "basic": { + "value": 909.0673828125, + "displayValue": "909" + } + }, + "performance_grade_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 15909, + "displayValue": "15,909" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/2ebb5872f93aec5b5b74cb407ad69913.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 1, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018447808357", + "displayName": "Mr_Kelevra_", + "bungieGlobalDisplayName": "Kelevra IX", + "bungieGlobalDisplayNameCode": 8817 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 723818653 + }, + "characterId": "2305843009279218509", + "values": { + "assists": { + "basic": { + "value": 25, + "displayValue": "25" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 50, + "displayValue": "50" + } + }, + "opponentsDefeated": { + "basic": { + "value": 75, + "displayValue": "75" + } + }, + "efficiency": { + "basic": { + "value": 75, + "displayValue": "75.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 50, + "displayValue": "50.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 62.5, + "displayValue": "62.50" + } + }, + "score": { + "basic": { + "value": 15909, + "displayValue": "15,909" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1063, + "displayValue": "17m 43s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": 7834403886865681000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 4, + "displayValue": "0m 4s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1059, + "displayValue": "17m 39s" + } + }, + "playerCount": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "teamScore": { + "basic": { + "value": 10909, + "displayValue": "10,909" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 2188764214, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.4, + "displayValue": "40%" + } + } + } + }, + { + "referenceId": 3176697588, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3157894736842105, + "displayValue": "32%" + } + } + } + }, + { + "referenceId": 954563454, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2, + "displayValue": "20%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "assists": { + "basic": { + "value": 25, + "displayValue": "25" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 50, + "displayValue": "50" + } + }, + "score": { + "basic": { + "value": 15909.0673828125, + "displayValue": "15,909" + } + }, + "partial_score": { + "basic": { + "value": 10909.0673828125, + "displayValue": "10,909" + } + }, + "display_total_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "display_team_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "display_time_bonus_points": { + "basic": { + "value": 909.0673828125, + "displayValue": "909" + } + }, + "performance_grade_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 15909, + "displayValue": "15,909" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/c47a032605d0fe2d20f81b4d79c8e8e1.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 3, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018537946823", + "displayName": "Morgoth2405", + "bungieGlobalDisplayName": "Morgoth2405", + "bungieGlobalDisplayNameCode": 2529 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 2803282938, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 690263481 + }, + "characterId": "2305843010621614327", + "values": { + "assists": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 79, + "displayValue": "79" + } + }, + "opponentsDefeated": { + "basic": { + "value": 106, + "displayValue": "106" + } + }, + "efficiency": { + "basic": { + "value": 106, + "displayValue": "106.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 79, + "displayValue": "79.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 92.5, + "displayValue": "92.50" + } + }, + "score": { + "basic": { + "value": 15909, + "displayValue": "15,909" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 1063, + "displayValue": "17m 43s" + } + }, + "completionReason": { + "basic": { + "value": 0, + "displayValue": "Objective Completed" + } + }, + "fireteamId": { + "basic": { + "value": 7834403886865681000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 4, + "displayValue": "0m 4s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 1059, + "displayValue": "17m 39s" + } + }, + "playerCount": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "teamScore": { + "basic": { + "value": 10909, + "displayValue": "10,909" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 71057630, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.16666666666666666, + "displayValue": "17%" + } + } + } + }, + { + "referenceId": 1435808083, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 17, + "displayValue": "17" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.17647058823529413, + "displayValue": "18%" + } + } + } + }, + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "33%" + } + } + } + }, + { + "referenceId": 2069224589, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 19, + "displayValue": "19" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 8, + "displayValue": "8" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "assists": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 79, + "displayValue": "79" + } + }, + "score": { + "basic": { + "value": 15909.0673828125, + "displayValue": "15,909" + } + }, + "partial_score": { + "basic": { + "value": 10909.0673828125, + "displayValue": "10,909" + } + }, + "display_total_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "display_team_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "display_time_bonus_points": { + "basic": { + "value": 909.0673828125, + "displayValue": "909" + } + }, + "performance_grade_multiplier": { + "basic": { + "value": 1, + "displayValue": "1" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/fixtures/pgcr-partial-completion-last-wish.json b/tests/fixtures/pgcr-partial-completion-last-wish.json new file mode 100644 index 0000000..7ab02e8 --- /dev/null +++ b/tests/fixtures/pgcr-partial-completion-last-wish.json @@ -0,0 +1,377 @@ +{ + "period": "2026-07-26T20:17:19Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": true, + "activityDifficultyTier": -1, + "activityDetails": { + "referenceId": 2122313384, + "directorActivityHash": 2122313384, + "instanceId": "17091283535", + "mode": 4, + "modes": [ + 7, + 4 + ], + "isPrivate": false, + "membershipType": 0 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/78a94beb470d9b8d41e82972f5999c9f.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018526091664", + "displayName": "venomeatsheads2", + "bungieGlobalDisplayName": "Xivu’s mushroom stamp", + "bungieGlobalDisplayNameCode": 1246 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 518, + "emblemHash": 707041058 + }, + "characterId": "2305843010153394212", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 594, + "displayValue": "9m 54s" + } + }, + "completionReason": { + "basic": { + "value": 2, + "displayValue": "Failed" + } + }, + "fireteamId": { + "basic": { + "value": -8456771659447240000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 309, + "displayValue": "5m 9s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 236, + "displayValue": "3m 56s" + } + }, + "playerCount": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/5cb593fe1e4dc2913b8f764390d38f2c.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018525602185", + "displayName": "Babydino2008", + "bungieGlobalDisplayName": "savathun dumper", + "bungieGlobalDisplayNameCode": 5847 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 465, + "emblemHash": 2770607178 + }, + "characterId": "2305843010308384088", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 1, + "displayValue": "Yes" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 594, + "displayValue": "9m 54s" + } + }, + "completionReason": { + "basic": { + "value": 2, + "displayValue": "Failed" + } + }, + "fireteamId": { + "basic": { + "value": -8456771659447240000, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 594, + "displayValue": "9m 54s" + } + }, + "playerCount": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/fixtures/pgcr-zero-completions-vault-of-glass.json b/tests/fixtures/pgcr-zero-completions-vault-of-glass.json new file mode 100644 index 0000000..3401080 --- /dev/null +++ b/tests/fixtures/pgcr-zero-completions-vault-of-glass.json @@ -0,0 +1,1683 @@ +{ + "period": "2026-07-26T21:21:21Z", + "startingPhaseIndex": 0, + "activityWasStartedFromBeginning": false, + "activityDifficultyTier": -1, + "activityDetails": { + "referenceId": 3022541210, + "directorActivityHash": 3022541210, + "instanceId": "17091467640", + "mode": 4, + "modes": [ + 7, + 4 + ], + "isPrivate": false, + "membershipType": 3 + }, + "entries": [ + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/19bcc057f9c9fb0bfedcfee2a3c0be16.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 3, + 6, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018453427950", + "displayName": "nukeguy2019", + "bungieGlobalDisplayName": "Paradox", + "bungieGlobalDisplayNameCode": 5045 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 898834093, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 4183788701 + }, + "characterId": "2305843009260351366", + "values": { + "assists": { + "basic": { + "value": 28, + "displayValue": "28" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 94, + "displayValue": "94" + } + }, + "opponentsDefeated": { + "basic": { + "value": 122, + "displayValue": "122" + } + }, + "efficiency": { + "basic": { + "value": 122, + "displayValue": "122.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 94, + "displayValue": "94.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 108, + "displayValue": "108.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 610, + "displayValue": "10m 10s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -395136662285001150, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 47, + "displayValue": "0m 47s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 510, + "displayValue": "8m 30s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 1111334348, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.5, + "displayValue": "50%" + } + } + } + }, + { + "referenceId": 1715391576, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 27, + "displayValue": "27" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/e348acdfbc0bbd3fe0849df1afe26d35.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 2, + 3, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018431673763", + "displayName": "Vital", + "bungieGlobalDisplayName": "techwood", + "bungieGlobalDisplayNameCode": 1879 + }, + "characterClass": "Warlock", + "classHash": 2271682572, + "raceHash": 2803282938, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 4011836831 + }, + "characterId": "2305843009271037958", + "values": { + "assists": { + "basic": { + "value": 4, + "displayValue": "4" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 86, + "displayValue": "86" + } + }, + "opponentsDefeated": { + "basic": { + "value": 90, + "displayValue": "90" + } + }, + "efficiency": { + "basic": { + "value": 90, + "displayValue": "90.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 86, + "displayValue": "86.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 88, + "displayValue": "88.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 610, + "displayValue": "10m 10s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -395136662285001150, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 4, + "displayValue": "0m 4s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 535, + "displayValue": "8m 55s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3285784871, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 6, + "displayValue": "6" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 4230965989, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2, + "displayValue": "20%" + } + } + } + }, + { + "referenceId": 3698448090, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 40, + "displayValue": "40" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.25, + "displayValue": "25%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 11, + "displayValue": "11" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 15, + "displayValue": "15" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/e0d1cde8ffe0012424afa6b352b9954d.jpg", + "crossSaveOverride": 0, + "applicableMembershipTypes": [ + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018471085014", + "displayName": "Americon", + "bungieGlobalDisplayName": "Americon", + "bungieGlobalDisplayNameCode": 9379 + }, + "characterClass": "Hunter", + "classHash": 671679327, + "raceHash": 2803282938, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 550, + "emblemHash": 3919847960 + }, + "characterId": "2305843009369429351", + "values": { + "assists": { + "basic": { + "value": 17, + "displayValue": "17" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 62, + "displayValue": "62" + } + }, + "opponentsDefeated": { + "basic": { + "value": 79, + "displayValue": "79" + } + }, + "efficiency": { + "basic": { + "value": 79, + "displayValue": "79.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 62, + "displayValue": "62.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 70.5, + "displayValue": "70.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 610, + "displayValue": "10m 10s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -395136662285001150, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 65, + "displayValue": "1m 5s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 513, + "displayValue": "8m 33s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3146657388, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 34, + "displayValue": "34" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.29411764705882354, + "displayValue": "29%" + } + } + } + }, + { + "referenceId": 839786290, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 1111334348, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 10, + "displayValue": "10" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/c362efc1d99ecd70b9df07b3b00623c3.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 2, + 3, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018433920495", + "displayName": "Jachrispyy", + "bungieGlobalDisplayName": "Jachrispy", + "bungieGlobalDisplayNameCode": 4529 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 548, + "emblemHash": 2770607176 + }, + "characterId": "2305843009529435267", + "values": { + "assists": { + "basic": { + "value": 43, + "displayValue": "43" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 143, + "displayValue": "143" + } + }, + "opponentsDefeated": { + "basic": { + "value": 186, + "displayValue": "186" + } + }, + "efficiency": { + "basic": { + "value": 186, + "displayValue": "186.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 143, + "displayValue": "143.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 164.5, + "displayValue": "164.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 610, + "displayValue": "10m 10s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -395136662285001150, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 4, + "displayValue": "0m 4s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 573, + "displayValue": "9m 33s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 42435996, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 104, + "displayValue": "104" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.019230769230769232, + "displayValue": "2%" + } + } + } + }, + { + "referenceId": 1802315656, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 2812324400, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/3cb129a210c036bcf13fb77de24aa0fa.jpg", + "crossSaveOverride": 3, + "applicableMembershipTypes": [ + 2, + 5, + 6, + 3 + ], + "isPublic": true, + "membershipType": 3, + "membershipId": "4611686018497674398", + "displayName": "Potato", + "bungieGlobalDisplayName": "Potato", + "bungieGlobalDisplayNameCode": 9715 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 548, + "emblemHash": 3079989875 + }, + "characterId": "2305843009623275804", + "values": { + "assists": { + "basic": { + "value": 9, + "displayValue": "9" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 87, + "displayValue": "87" + } + }, + "opponentsDefeated": { + "basic": { + "value": 96, + "displayValue": "96" + } + }, + "efficiency": { + "basic": { + "value": 96, + "displayValue": "96.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 87, + "displayValue": "87.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 91.5, + "displayValue": "91.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 610, + "displayValue": "10m 10s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -395136662285001150, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 5, + "displayValue": "0m 5s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 573, + "displayValue": "9m 33s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 3549153978, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 68, + "displayValue": "68" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3285784871, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.2, + "displayValue": "20%" + } + } + } + }, + { + "referenceId": 593808239, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 3, + "displayValue": "3" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.3333333333333333, + "displayValue": "33%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/61f8687b3b68d9c9dc5e734d21f7d24f.jpg", + "crossSaveOverride": 1, + "applicableMembershipTypes": [ + 2, + 3, + 1 + ], + "isPublic": true, + "membershipType": 1, + "membershipId": "4611686018435633288", + "displayName": "blaknite4477", + "bungieGlobalDisplayName": "blaknite4477", + "bungieGlobalDisplayNameCode": 2597 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 898834093, + "genderHash": 3111576190, + "characterLevel": 50, + "lightLevel": 545, + "emblemHash": 3267552999 + }, + "characterId": "2305843009890475407", + "values": { + "assists": { + "basic": { + "value": 13, + "displayValue": "13" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 53, + "displayValue": "53" + } + }, + "opponentsDefeated": { + "basic": { + "value": 66, + "displayValue": "66" + } + }, + "efficiency": { + "basic": { + "value": 66, + "displayValue": "66.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 53, + "displayValue": "53.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 59.5, + "displayValue": "59.50" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 610, + "displayValue": "10m 10s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": -395136662285001150, + "displayValue": "-2147483648" + } + }, + "startSeconds": { + "basic": { + "value": 4, + "displayValue": "0m 4s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 573, + "displayValue": "9m 33s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "weapons": [ + { + "referenceId": 334964261, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 33, + "displayValue": "33" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 12, + "displayValue": "12" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0.36363636363636365, + "displayValue": "36%" + } + } + } + }, + { + "referenceId": 2069224589, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 2, + "displayValue": "2" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + }, + { + "referenceId": 3377522331, + "values": { + "uniqueWeaponKills": { + "basic": { + "value": 5, + "displayValue": "5" + } + }, + "uniqueWeaponPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "uniqueWeaponKillsPrecisionKills": { + "basic": { + "value": 0, + "displayValue": "0%" + } + } + } + } + ], + "values": { + "precisionKills": { + "basic": { + "value": 12, + "displayValue": "12" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 11, + "displayValue": "11" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 1, + "displayValue": "1" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + }, + { + "standing": 0, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "player": { + "destinyUserInfo": { + "iconPath": "/common/destiny2_content/icons/31d09629d4761a859f86d232936a907d.jpg", + "crossSaveOverride": 2, + "applicableMembershipTypes": [ + 3, + 6, + 2 + ], + "isPublic": true, + "membershipType": 2, + "membershipId": "4611686018453427950", + "displayName": "nukeguy2019", + "bungieGlobalDisplayName": "Paradox", + "bungieGlobalDisplayNameCode": 5045 + }, + "characterClass": "Titan", + "classHash": 3655393761, + "raceHash": 2803282938, + "genderHash": 2204441813, + "characterLevel": 50, + "lightLevel": 1582, + "emblemHash": 1918663075 + }, + "characterId": "2305843009986754242", + "values": { + "assists": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "completed": { + "basic": { + "value": 0, + "displayValue": "No" + } + }, + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "opponentsDefeated": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "efficiency": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsRatio": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "killsDeathsAssists": { + "basic": { + "value": 0, + "displayValue": "0.00" + } + }, + "score": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "activityDurationSeconds": { + "basic": { + "value": 610, + "displayValue": "10m 10s" + } + }, + "completionReason": { + "basic": { + "value": 255, + "displayValue": "Unknown" + } + }, + "fireteamId": { + "basic": { + "value": 0, + "displayValue": "" + } + }, + "startSeconds": { + "basic": { + "value": 0, + "displayValue": "0m 0s" + } + }, + "timePlayedSeconds": { + "basic": { + "value": 25, + "displayValue": "0m 25s" + } + }, + "playerCount": { + "basic": { + "value": 7, + "displayValue": "7" + } + }, + "teamScore": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "extended": { + "values": { + "precisionKills": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsGrenade": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsMelee": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsSuper": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "weaponKillsAbility": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + }, + "scoreboardValues": { + "deaths": { + "basic": { + "value": 0, + "displayValue": "0" + } + }, + "kills": { + "basic": { + "value": 0, + "displayValue": "0" + } + } + } + } + } + ], + "teams": [] +} \ No newline at end of file diff --git a/tests/helpers/pgcr-builder.ts b/tests/helpers/pgcr-builder.ts index 63c4f74..973aa08 100644 --- a/tests/helpers/pgcr-builder.ts +++ b/tests/helpers/pgcr-builder.ts @@ -108,8 +108,9 @@ export interface PGCROptions { period?: string; activityWasStartedFromBeginning?: boolean; /** - * Bungie no longer sends this — it is absent on all 827k stored rows. Pass - * `null` to reproduce reality; pass a number only when testing the legacy path. + * Bungie reports this as 0 on every real PGCR, including checkpoint runs, so it + * no longer distinguishes anything. Pass `null` to omit it entirely; pass a + * number only when testing the legacy path. */ startingPhaseIndex?: number | null; entries?: DestinyPostGameCarnageReportEntry[]; diff --git a/tests/helpers/seed.ts b/tests/helpers/seed.ts index 670a60c..c6c709c 100644 --- a/tests/helpers/seed.ts +++ b/tests/helpers/seed.ts @@ -1,6 +1,9 @@ import { getDb } from '@/lib/db'; import { insertFullPGCR, upsertPlayer } from '@/lib/db/queries'; import { getRaidKeyFromHash } from '@/lib/bungie/manifest'; +import { processPGCR } from '@/lib/crawler/pgcr'; +import { readActivityDurationSeconds, readEntryStartSeconds } from '@/lib/bungie/pgcr-stats'; +import type { DestinyPostGameCarnageReportData } from '@/lib/bungie/types'; import { RAID_HASH } from './pgcr-builder'; /** @@ -67,8 +70,8 @@ export function seedRun(options: SeedRunOptions): void { activityHash, raidKey, period, - // Always 0: Bungie no longer sends startingPhaseIndex, and the writer - // coerces it with `|| 0` anyway. Checkpoint runs are expressed through + // Always 0: Bungie reports startingPhaseIndex as 0 on every run, and the + // writer coerces it with `|| 0` anyway. Checkpoint runs are expressed through // startedFromBeginning, which is what the leaderboards actually filter on. startingPhaseIndex: 0, activityWasStartedFromBeginning: startedFromBeginning, @@ -114,6 +117,48 @@ export function seedPlayer( }); } +/** + * Ingests a real captured PGCR through the same path the crawler uses. + * + * Mirrors the body of `fetchAndStorePGCR` minus the network call — the mapping + * from Bungie's entry shape to our storage shape is duplicated there rather than + * extracted, so this reproduces it exactly. If that mapping ever changes, this + * must change with it. + */ +export function seedFromFixture(pgcr: DestinyPostGameCarnageReportData, source = 'test'): void { + const processed = processPGCR(pgcr); + + insertFullPGCR( + { + instanceId: processed.instanceId, + activityHash: processed.activityHash, + raidKey: processed.raidKey, + period: processed.period, + startingPhaseIndex: pgcr.startingPhaseIndex || 0, + activityWasStartedFromBeginning: pgcr.activityWasStartedFromBeginning || false, + completed: processed.completed, + playerCount: pgcr.entries.length, + source, + activityDurationSeconds: readActivityDurationSeconds(pgcr.entries), + }, + pgcr.entries.map((entry) => ({ + instanceId: processed.instanceId, + membershipId: entry.player.destinyUserInfo.membershipId, + membershipType: entry.player.destinyUserInfo.membershipType, + displayName: entry.player.destinyUserInfo.displayName, + bungieGlobalDisplayName: entry.player.destinyUserInfo.bungieGlobalDisplayName, + characterClass: entry.player.characterClass || 'Unknown', + lightLevel: entry.player.lightLevel || 0, + completed: entry.values?.completed?.basic?.value === 1, + kills: entry.values?.kills?.basic?.value || 0, + deaths: entry.values?.deaths?.basic?.value || 0, + assists: entry.values?.assists?.basic?.value || 0, + timePlayedSeconds: entry.values?.timePlayedSeconds?.basic?.value || 0, + startSeconds: readEntryStartSeconds(entry), + })) + ); +} + /** Raw row read, for asserting what the writer actually persisted. */ export function readPgcrRow(instanceId: string): Record | undefined { return getDb() diff --git a/tests/real-pgcrs.test.ts b/tests/real-pgcrs.test.ts new file mode 100644 index 0000000..db9aef5 --- /dev/null +++ b/tests/real-pgcrs.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { processPGCR } from '@/lib/crawler/pgcr'; +import { computeActivityDurationSeconds } from '@/lib/db/queries'; +import { isRaidActivityHash } from '@/lib/bungie/manifest'; +import { readActivityDurationSeconds, readEntryStartSeconds } from '@/lib/bungie/pgcr-stats'; +import type { DestinyPostGameCarnageReportData } from '@/lib/bungie/types'; +import { resetTestDb, testDb } from './helpers/db'; +import { seedFromFixture } from './helpers/seed'; + +import absurdDuration from './fixtures/pgcr-absurd-duration-crotas-end.json'; +import checkpoint from './fixtures/pgcr-checkpoint-root-of-nightmares.json'; +import fullClear from './fixtures/pgcr-fullclear-salvations-edge.json'; +import missingNames from './fixtures/pgcr-missing-bungie-name.json'; +import multiCharacter from './fixtures/pgcr-multi-character-garden.json'; +import nonRaid from './fixtures/pgcr-non-raid.json'; +import partialCompletion from './fixtures/pgcr-partial-completion-last-wish.json'; +import zeroCompletions from './fixtures/pgcr-zero-completions-vault-of-glass.json'; + +/** + * Tests against real captured Bungie responses. + * + * The builder-based tests cover permutations we construct. These cover the + * shapes Bungie actually sends, which repeatedly turn out to be stranger than + * anything we would think to build: nineteen entries in one report, six entries + * belonging to two people, a seven-hour "duration" on an eighteen-minute raid. + * + * See tests/fixtures/README.md for what each file is and how it was captured. + */ + +const as = (fixture: unknown) => fixture as DestinyPostGameCarnageReportData; + +beforeEach(() => { + resetTestDb(); +}); + +describe('a clean full clear', () => { + it('is recognised as a completed raid', () => { + const result = processPGCR(as(fullClear)); + + expect(result.raidKey).toBe('salvations_edge'); + expect(result.completed).toBe(true); + expect(result.players).toHaveLength(6); + }); +}); + +describe('a checkpoint run', () => { + it('is reported by Bungie as not started from the beginning', () => { + expect(as(checkpoint).activityWasStartedFromBeginning).toBe(false); + }); + + it('still carries a zero starting phase index', () => { + // The captured proof that startingPhaseIndex no longer discriminates + // anything: Bungie sends 0 even here, on a confirmed checkpoint run. This + // is why ProcessedPGCR.isFullClear reports true for every run — its + // `startingPhaseIndex === 0` branch always fires. See docs/decisions.md. + expect(as(checkpoint).startingPhaseIndex).toBe(0); + }); + + it('is excluded from the leaderboard once ingested', () => { + seedFromFixture(as(checkpoint)); + + const row = testDb() + .prepare('SELECT activity_was_started_from_beginning AS f FROM pgcrs WHERE instance_id = ?') + .get(as(checkpoint).activityDetails.instanceId) as { f: number }; + + expect(row.f).toBe(0); + }); +}); + +describe('every captured raid agrees on the phase index', () => { + it('reports 0 regardless of how the run was entered', () => { + // Four full clears and three checkpoint runs, all reporting 0. A field that + // takes one value across every observed case cannot be used to tell them + // apart, whatever the type definition implies. + const all = [fullClear, checkpoint, zeroCompletions, partialCompletion, multiCharacter, absurdDuration, missingNames]; + + expect(all.map((f) => as(f).startingPhaseIndex)).toEqual([0, 0, 0, 0, 0, 0, 0]); + }); +}); + +describe('a run nobody completed', () => { + it('is not counted as completed', () => { + expect(processPGCR(as(zeroCompletions)).completed).toBe(false); + }); +}); + +describe('a run where one of two players finished', () => { + it('counts as completed, because any completion counts', () => { + expect(processPGCR(as(partialCompletion)).completed).toBe(true); + }); +}); + +describe('a player who brought several characters', () => { + it('appears once per character in Bungie\'s entries', () => { + const entries = as(multiCharacter).entries; + const distinct = new Set(entries.map((e) => e.player.destinyUserInfo.membershipId)); + + expect(entries).toHaveLength(6); + expect(distinct.size).toBe(2); + }); + + it('collapses to one stored row per player', () => { + // pgcr_players is keyed (instance_id, membership_id) and inserts use + // INSERT OR IGNORE, so the second and third characters are dropped. The + // leaderboard's COUNT(DISTINCT instance_id) would handle duplicates anyway, + // but they never reach it. + seedFromFixture(as(multiCharacter)); + + const row = testDb() + .prepare('SELECT COUNT(*) AS c FROM pgcr_players WHERE instance_id = ?') + .get(as(multiCharacter).activityDetails.instanceId) as { c: number }; + + expect(row.c).toBe(2); + }); + + it('keeps only the first character\'s stats, not the largest or the sum', () => { + // Documented, not endorsed. The first entry for this player reports 981s + // played; their longest character reports 1494s. Nothing reads these + // columns today, so this is latent rather than user-visible. + seedFromFixture(as(multiCharacter)); + + const row = testDb() + .prepare( + 'SELECT time_played_seconds AS t FROM pgcr_players WHERE instance_id = ? AND membership_id = ?' + ) + .get(as(multiCharacter).activityDetails.instanceId, '4611686018462874397') as { t: number }; + + expect(row.t).toBe(981); + }); + + it('still measures the activity across all characters', () => { + // Duration is computed from the in-memory entries, before the dedupe, so + // the dropped rows do not shorten the run. + const entries = as(multiCharacter).entries; + const players = entries.map((e) => ({ + startSeconds: readEntryStartSeconds(e), + timePlayedSeconds: e.values.timePlayedSeconds?.basic?.value ?? 0, + })); + + expect(computeActivityDurationSeconds(null, players)).toBe(2037); + }); +}); + +describe('a run with an absurd reported duration', () => { + it('is taken at face value by the duration tiers', () => { + // Tier 1 trusts Bungie: 27384s (7.6 hours) for a raid where nobody played + // past 1093s. The tiers deliberately do not sanity-check this — the + // future-end-time guard in insertFullPGCR is what catches it, and only + // when the resulting end time lands ahead of the ingest clock. + const entries = as(absurdDuration).entries; + const players = entries.map((e) => ({ + startSeconds: readEntryStartSeconds(e), + timePlayedSeconds: e.values.timePlayedSeconds?.basic?.value ?? 0, + })); + + expect(readActivityDurationSeconds(entries)).toBe(27384); + expect(computeActivityDurationSeconds(27384, players)).toBe(27384); + }); + + it('would derive a far shorter run from per-player time alone', () => { + const entries = as(absurdDuration).entries; + const players = entries.map((e) => ({ + startSeconds: readEntryStartSeconds(e), + timePlayedSeconds: e.values.timePlayedSeconds?.basic?.value ?? 0, + })); + + expect(computeActivityDurationSeconds(null, players)).toBe(1093); + }); +}); + +describe('a report where Bungie withholds every player identity', () => { + it('extracts all nineteen entries without throwing', () => { + // Nineteen entries in one raid report. Neither the count nor the total + // absence of names is something a hand-written fixture would have said. + const result = processPGCR(as(missingNames)); + + expect(result.players).toHaveLength(19); + expect(result.players.every((p) => p.bungieGlobalDisplayName === undefined)).toBe(true); + }); + + it('has no platform display name to fall back to either', () => { + // Worth stating plainly: this is not "the global name is missing so use the + // platform one". Every entry arrives as isPublic: false with membershipType + // 0 and no name field of any kind, so there is no fallback left. The + // downstream display path ends up rendering a raw membership id. + const userInfo = as(missingNames).entries.map((e) => e.player.destinyUserInfo); + + expect(userInfo.every((u) => u.displayName === undefined)).toBe(true); + expect(userInfo.every((u) => u.isPublic === false)).toBe(true); + expect(processPGCR(as(missingNames)).players.every((p) => p.displayName === undefined)).toBe(true); + }); + + it('stores all nineteen rows with null names rather than rejecting them', () => { + // The run is still real and still counts, so dropping it would lose a + // genuine raid. NULL names are the correct outcome here. + seedFromFixture(as(missingNames)); + + const rows = testDb() + .prepare( + 'SELECT COUNT(*) AS c, COUNT(display_name) AS named FROM pgcr_players WHERE instance_id = ?' + ) + .get(as(missingNames).activityDetails.instanceId) as { c: number; named: number }; + + expect(rows.c).toBe(19); + expect(rows.named).toBe(0); + }); + + it('records membershipType 0, which is not a real platform', () => { + // Type "None". These ids cannot be resolved against a platform without a + // LinkedProfiles lookup, which is what scripts/cleanup exists to repair. + seedFromFixture(as(missingNames)); + + const row = testDb() + .prepare('SELECT DISTINCT membership_type AS t FROM pgcr_players WHERE instance_id = ?') + .get(as(missingNames).activityDetails.instanceId) as { t: number }; + + expect(row.t).toBe(0); + }); +}); + +describe('a non-raid activity', () => { + it('is rejected by raid detection', () => { + const hash = + as(nonRaid).activityDetails.directorActivityHash || as(nonRaid).activityDetails.referenceId; + + expect(isRaidActivityHash(hash)).toBe(false); + }); + + it('resolves to no raid key', () => { + expect(processPGCR(as(nonRaid)).raidKey).toBeUndefined(); + }); +}); From 32633adf921924b0508dd3d95e3289a992510b02 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:59:59 -0400 Subject: [PATCH 14/17] fix docs --- .gitignore | 5 +- docs/testing-framework-plan.md | 173 --------------------------------- 2 files changed, 4 insertions(+), 174 deletions(-) delete mode 100644 docs/testing-framework-plan.md diff --git a/.gitignore b/.gitignore index 18fbaa3..4a10d02 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,7 @@ localhost.key # Sentry Config File .env.sentry-build-plugin -certificates \ No newline at end of file +certificates + +#handoff docs +docs/handoffs/ \ No newline at end of file diff --git a/docs/testing-framework-plan.md b/docs/testing-framework-plan.md deleted file mode 100644 index be6e0c0..0000000 --- a/docs/testing-framework-plan.md +++ /dev/null @@ -1,173 +0,0 @@ -# Testing framework — plan of record - -**Branch:** `test-framework` -**Date:** 2026-07-26 -**Supersedes:** the original `testingframeworksetupplan.md` brief, in the four places noted below. - -This is the agreed plan after a recon pass over the repo and a decision-by-decision review. It -exists because the original brief was written against assumptions that the code no longer holds, -and someone reading the resulting commits will otherwise wonder why the phases don't match the -brief they were given. - ---- - -## 1. Where the repo contradicted the brief - -The brief instructed: *"Do not guess at any of the above. If something contradicts this document, -trust the repo and tell me."* Four of its seven phases rested on premises that no longer hold. - -### 1.1 Phase 5 had nothing left to protect - -The brief called the `ended_at` cutover "in-flight" and Phase 5 "the highest-value part of the -task" — a parity net for roughly ten SQL sites still using the `run_durations` CTE. - -The cutover already shipped, in `610408e` *"leaderboard denormalization phase 3b — readers -cutover, drop dead indexes"*. There are **zero** `run_durations` references anywhere in `src/`. -The only survivors are inside `scripts/verify-phase3-cutover.ts`, the one-off parity script that -already performed exactly this comparison against the production database. - -### 1.2 Phase 3's centerpiece is dead code — and would be wrong if used - -The brief identified `processPGCR`'s three-way `||` on `isFullClear` as "the riskiest logic in the -codebase" and devoted 5 of its 11 Phase 3 cases to it. - -`ProcessedPGCR.isFullClear` is computed at `src/lib/crawler/pgcr.ts:36`, returned, and **never -read by anything**. `fetchAndStorePGCR` persists `pgcrData.activityWasStartedFromBeginning` — the -raw Bungie field — and every leaderboard filters on that column instead -(`leaderboard-cache.ts:175`, `queries.ts:760/781/820/855`). - -That matters more than "unused code," because the local database shows the derivation is also -wrong. Across 827,076 rows: - -| Field | Distribution | -|---|---| -| `starting_phase_index` | `0` → 827,076 (**100%**) | -| `activity_was_started_from_beginning` | `0` → 568,648 · `1` → 258,426 | -| `completed` | `0` → 456,009 · `1` → 371,070 | - -Bungie now reports `startingPhaseIndex: 0` on every PGCR. Confirmed against live captures in -`tests/fixtures/`, including checkpoint runs where `activityWasStartedFromBeginning` is `false` — -the field is present but no longer discriminates anything. So the `startingPhaseIndex === 0` branch -fires unconditionally and `isFullClear` is `true` for **100%** of runs, including the 568,648 that -are genuinely not full clears. It is inert only because nothing consumes it. Wired -up, it would inflate every leaderboard by roughly 2.2×. - -Consequence for fixtures: the brief's requested "checkpoint run (`startingPhaseIndex > 0`)" -fixture **cannot be captured**, because no such row exists in 827k records. - -### 1.3 Phase 4 pointed at the wrong file - -The leaderboard SQL is `runLeaderboardRows` in `src/lib/cache/leaderboard-cache.ts:146`, not -`src/lib/db/queries.ts`. Two of the brief's bullets don't apply: `fullClearsOnly` is forced `true` -on every code path (there is no `false` branch to test), and the `run_durations` zero-completion -case is obsolete per §1.1. - -The real query has edges the brief never mentions — a three-key tie-break -(`completions DESC, lastClearAt ASC, membership_id ASC`), competition-style rank assignment across -tie groups, a `LEFT JOIN players` name fallback, and `formatDisplayName`'s `padStart(4, '0')`. - -### 1.4 Phase 6 has no retry logic - -The brief asked for "retry/backoff behavior, using Vitest fake timers rather than real sleeps." -`BungieClient.request()` contains no retry — it classifies errors and pauses a shared rate limiter, -then throws. The real fake-timer target is `RateLimiter` (`src/lib/utils/rate-limiter.ts`), a FIFO -promise chain with a subtle mid-sleep `pauseFor` re-read. - -Also, `isBungieSystemDisabledError` is two lines (`maintenance.ts:259`), not a subsystem. - -### 1.5 What the brief got right - -- `getDb()` **is** already injectable, via `RAID_TRACKER_DB_PATH` (`db/index.ts:7`). -- Schema creation **is** programmatic: `initializeSchema()` in `src/lib/db/schema.ts`, invoked by - `getDb()`. `ended_at` arrives through an `ALTER TABLE` migration guard (`schema.ts:120-123`), so - a fresh database gets the column and the Phase-3 indexes automatically. -- `/coverage` is already gitignored. `npm run lint` passes clean. Local Node is v22.18.0. -- `processPGCR`'s signature and shape match the brief exactly. - -**No application code changes are required by any phase.** - -One further correction, outside the brief: raid detection does **not** read -`data/manifest-cache.json` at runtime. `RAID_DEFINITIONS` is a hardcoded literal in -`src/lib/bungie/manifest.ts:16`; the cache file is only *written* by `setup-manifest` for human -review. This makes `isRaidActivityHash` hermetic, which is good for tests, but CLAUDE.md is -misleading on the point. - ---- - -## 2. Decisions - -| # | Area | Decision | -|---|---|---| -| 1 | Phase 5 | Retarget to `computeActivityDurationSeconds` — the three-tier duration fallback and the `FUTURE_ENDED_SKEW_SECONDS` corruption guard — asserted end-to-end through `insertFullPGCR`. No parity testing against the removed CTE. | -| 2 | Phase 3 | Test the **persisted** full-clear signal, not `isFullClear`. The dead field is documented for removal in a future change, not removed on this branch. | -| 3 | Fixtures | A committed capture script, seeded with instance IDs pulled from the local database, is run **by the maintainer**. `.env` is never read. | -| 4 | Test DB | `makeTestDb()` uses a per-file `mkdtemp` directory via `RAID_TRACKER_DB_PATH`. See ADR 0003. | -| 5 | Phase 4 | Surviving brief bullets, plus the query's real edges, plus the SQL-vs-JS boundary. | -| 6 | Phase 6 | The predicate, `request()`'s error dispatch behind a stubbed `fetch`, and `RateLimiter` under fake timers. Plus a global guard against unstubbed outbound requests. | -| 7 | CI | `npm ci` · `npm run lint` · `tsc --noEmit` · `npm test`, on Node 22. Typecheck added because nothing else catches type errors without a full `next build`. | -| 8 | Commits | `package-lock.json` isolated in its own commit first, then one scoped commit per phase. Pre-existing untracked files left untouched. | -| 9 | Glossary | Sharpen **Full Clear**; add **Checkpoint Run** and **Completion**. | -| 10 | Records | ADR 0003 (test-database strategy), ADR 0004 (testing policy), and a `docs/decisions.md` entry. | - -### Why the test database is a temp file, not `:memory:` - -Verified empirically rather than assumed: - -``` -:memory: journal_mode = WAL -> 'memory' (silently ignored) -file journal_mode = WAL -> 'wal' -``` - -SQLite cannot put an in-memory database into WAL mode, so `:memory:` would exercise different -journal semantics than production — undercutting the entire premise that a real database -"actually validates the SQL." A temp path also isolates `DATA_DIR` for free, because it derives -from `dirname(RAID_TRACKER_DB_PATH)` (`maintenance/state.ts:4-8`). That matters: `getDb()` calls -`isDbQuiesceActive()` on **every** invocation, which reads `data/maintenance-state.json` from -disk — so a suite pointed at the real data directory would fail every test with -`DatabaseMaintenanceError` if run during a maintenance vacuum. - -Cost is a `mkdtemp` plus schema init per test file, roughly 5–15 ms on tmpfs. At this suite size -the speed argument for `:memory:` does not survive the numbers. - ---- - -## 3. Execution order - -**Phase 1 — install and wire up Vitest.** -`vitest.config.ts`, npm scripts, and the `test-maintenance-cycle` → `e2e:maintenance` rename with -every reference updated. -*Gate: demonstrate a passing run, then a deliberately broken assertion failing with a readable -diff, then delete the throwaway. **Stop and report.*** - -**Phase 2 — fixtures.** -Commit the capture script. ***Forced pause: the maintainer runs it.*** Then the builders in -`tests/helpers/` and `tests/fixtures/README.md`. - -**Phases 3 → 7 — run straight through**, one scoped commit each, then the docs commit. - -Two mandatory stops: the Phase 1 gate, and the fixture capture. - ---- - -## 4. Out of scope - -No React, DOM, or jsdom testing. No Playwright. No coverage thresholds or gates — the reporter is -installed, no number is enforced. No mocking of our own modules; the network boundary only. No -tests against the real database file or the real Bungie API. `scripts/test-maintenance-cycle.ts` -is not ported, rewritten, or absorbed — it changes only by script name. No cutover is performed. -The `isFullClear` defect is reported, not fixed. - ---- - -## 5. Findings to report, not fix - -1. **`ProcessedPGCR.isFullClear` is dead and would be wrong if used.** See §1.2. Flagged for - removal in a future change. -2. **`formatDisplayName` drops the `#code` when the code is falsy** - (`leaderboard-cache.ts:135`), contradicting the `Name#Code` invariant CLAUDE.md calls - load-bearing. -3. **`getDb()` reads `maintenance-state.json` from disk on every call**, not just on open. -4. **CLAUDE.md's raid-detection description is inaccurate** — see the note at the end of §1.5. -5. **55% of stored PGCRs (456,009 of 827,076) have zero completed players.** Not a defect on its - own, but it is the dominant shape in the table and worth knowing when reasoning about any - query that joins through completions. From 05335324e2df7308aad88258109befbe7976f1b5 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:44:32 -0400 Subject: [PATCH 15/17] feat: link StatsBar counts to their pages; make the freshness slot clock-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two counts now navigate to /leaderboard and /active-sessions. They carry no styling of their own — colour, weight and underline all inherit, so the strip is pixel-identical for mouse and touch users; a focus-visible ring is the only addition, and it fires for keyboard navigation only. Deletes FooterStatus, whose "Updated Xs ago" the StatsBar ticker supersedes. These were never duplicates, though: the footer reported the *crawler heartbeat* (how stale the data is) while the StatsBar ticker reports *this tab's last fetch* (how stale the page is). They diverge exactly when it matters — a dead crawler left the bar reading "Updates paused - Updated 8s ago". So the freshness slot now picks its clock: page age while live, data age once the heartbeat lapses, and nothing at all when the crawler is down and its age is unknown (the "Maintenance" label already says everything true there). That three-way choice is extracted to selectFreshness() so it can be tested under vitest's node environment, which has no DOM by design. Docs: CONTEXT.md gains Heartbeat / Data Freshness / Page Freshness — the missing vocabulary that let one phrase mean two things in the first place. ADR 0002 notes that its count-vs-list rule now extends to navigation: the links deliberately don't override a reader's saved time range or raid filters. --- CONTEXT.md | 15 +++++ .../0002-session-count-reports-true-total.md | 4 ++ src/app/api/live-stats/route.ts | 5 +- src/app/globals.css | 4 -- src/app/layout.tsx | 3 - src/components/FooterStatus.tsx | 31 ----------- src/components/StatsBar.tsx | 42 +++++++++++--- src/components/freshness.test.ts | 55 +++++++++++++++++++ src/components/freshness.ts | 48 ++++++++++++++++ src/hooks/useLiveStats.ts | 2 +- 10 files changed, 160 insertions(+), 49 deletions(-) delete mode 100644 src/components/FooterStatus.tsx create mode 100644 src/components/freshness.test.ts create mode 100644 src/components/freshness.ts diff --git a/CONTEXT.md b/CONTEXT.md index d87a1cb..a731719 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -50,6 +50,21 @@ characters they brought to it. The unit every leaderboard ranks by. A player bei cleared raid is not enough — they must have finished it themselves. _Avoid_: clear, kill, run +**Heartbeat**: +The crawler's periodic signal that it is still observing. Its age is the Data Freshness; once it +lapses, the site reports itself as no longer live. +_Avoid_: ping, health check, status + +**Data Freshness**: +How long ago the crawler last confirmed it was working — the age of what the site knows. The only +freshness that says anything about whether a leaderboard or an active session can be trusted. +_Avoid_: last updated, uptime + +**Page Freshness**: +How long ago a browser tab last fetched — the age of what is on screen. Says nothing about whether +the data behind it is current: a tab can be seconds old and still be displaying hours-old data. +_Avoid_: last updated, refresh time + **Farm**: Repeatedly replaying a single raid encounter or checkpoint for rewards, rather than progressing through the raid. The activity the site is named for. diff --git a/docs/adr/0002-session-count-reports-true-total.md b/docs/adr/0002-session-count-reports-true-total.md index 91e0556..0a0b285 100644 --- a/docs/adr/0002-session-count-reports-true-total.md +++ b/docs/adr/0002-session-count-reports-true-total.md @@ -20,3 +20,7 @@ differs. bites, and is not a bug to be "fixed" by capping the count. - The server logs a warning whenever the cap bites, since the default (600) sits close to observed prod volume and the gap would otherwise be invisible. +- The StatsBar counts link to `/leaderboard` and `/active-sessions` without carrying a time range or + clearing raid filters. The destination honours whatever view the user saved, so the same expected + discrepancy extends to navigation: clicking "full clears · last 24h" can land a reader on their + own saved 7-day board, and that is not a broken link. diff --git a/src/app/api/live-stats/route.ts b/src/app/api/live-stats/route.ts index 787e0ad..0974532 100644 --- a/src/app/api/live-stats/route.ts +++ b/src/app/api/live-stats/route.ts @@ -6,8 +6,9 @@ import { getBungieMaintenanceStatus } from '@/lib/bungie/maintenance'; import { withCache, withNoStore } from '@/lib/http/cache'; // Informational feed for the StatsBar — always 200, never a health check (/api/status owns that). -// Also carries the maintenance flags and heartbeat consumed by FooterStatus and -// BungieMaintenanceAlert, so browser tabs poll this single endpoint. +// Also carries the maintenance flags and the heartbeat the StatsBar falls back to +// when the crawler goes stale, plus what BungieMaintenanceAlert needs, so browser +// tabs poll this single endpoint. interface LiveStatsPayload { live: boolean; diff --git a/src/app/globals.css b/src/app/globals.css index 63dac40..a34e030 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -238,8 +238,4 @@ body { outline: 2px solid var(--ui-accent); outline-offset: 2px; } - - .footer-status { - color: var(--ui-text-muted); - } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3df4492..8cb232e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,7 +8,6 @@ import PlayerSearch from '@/components/PlayerSearch'; import StatsBar from '@/components/StatsBar'; import ThemeToggle from '@/components/ThemeToggle'; import BungieMaintenanceAlert from '@/components/BungieMaintenanceAlert'; -import FooterStatus from '@/components/FooterStatus'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://destinyfarmfinder.qzz.io'; @@ -135,8 +134,6 @@ export default function RootLayout({ > - - diff --git a/src/components/FooterStatus.tsx b/src/components/FooterStatus.tsx deleted file mode 100644 index ae218a5..0000000 --- a/src/components/FooterStatus.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client'; - -import { useLiveStats } from '@/hooks/useLiveStats'; - -function formatFreshness(secondsSinceHeartbeat?: number | null): string { - if (secondsSinceHeartbeat == null || Number.isNaN(secondsSinceHeartbeat)) { - return 'Status unavailable'; - } - - if (secondsSinceHeartbeat < 60) { - return `Updated ${Math.max(0, Math.floor(secondsSinceHeartbeat))}s ago`; - } - - const minutes = Math.floor(secondsSinceHeartbeat / 60); - if (minutes < 60) { - return `Updated ${minutes}m ago`; - } - - const hours = Math.floor(minutes / 60); - return `Updated ${hours}h ago`; -} - -export default function FooterStatus() { - const { stats } = useLiveStats(); - - const label = stats - ? formatFreshness(stats.secondsSinceHeartbeat) - : 'Checking status...'; - - return {label}; -} diff --git a/src/components/StatsBar.tsx b/src/components/StatsBar.tsx index 81f0f9c..2086c32 100644 --- a/src/components/StatsBar.tsx +++ b/src/components/StatsBar.tsx @@ -1,8 +1,18 @@ 'use client'; +import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import { usePageLiveStatus } from '@/hooks/usePageLiveStatus'; import { useLiveStats, LIVE_STATS_POLL_INTERVAL_MS } from '@/hooks/useLiveStats'; +import { selectFreshness } from './freshness'; + +// The counts link to the pages they summarise, but deliberately carry no styling +// of their own: colour, weight and underline all inherit, so the strip looks +// identical and the link is only there for whoever clicks or taps it. The +// focus-visible ring is the one exception — it fires for keyboard navigation +// only, which mouse and touch users never see. +const STAT_LINK_CLASS = + 'flex items-center gap-1 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-accent)]'; function formatSecondsAgo(seconds: number | null): string { if (seconds === null) return 'never'; @@ -68,6 +78,16 @@ export default function StatsBar() { const refreshSec = pageStatus?.refreshIntervalSec ?? LIVE_STATS_POLL_INTERVAL_MS / 1000; + // Note the asymmetry: the page clock ticks locally every second, while the + // data clock is a server snapshot refreshed only once per poll — so the + // stale reading is rendered as-is rather than counted up. Inventing seconds + // between polls would be fiction, and at this age they carry no information. + const freshness = selectFreshness({ + live: stats?.live ?? false, + secondsSinceHeartbeat: stats?.secondsSinceHeartbeat ?? null, + secondsSincePageUpdate: updatedAtMs === null ? null : secondsAgo, + }); + const liveLabel = stats?.maintenance ? 'Maintenance' : stats?.live @@ -94,25 +114,31 @@ export default function StatsBar() { {liveLabel} -
+ full clears · last 24h clears -
+ -
+ {stats.activeRaidSessions === 1 ? 'fireteam' : 'fireteams'} raiding now raiding -
- {updatedAtMs !== null && ( + + {freshness.kind !== 'none' && ( <> - - Updated {formatSecondsAgo(secondsAgo)} - {formatSecondsShort(secondsAgo)} + {/* The stale slot reuses the live dot's tooltip: same number, same + sentence, so they can never drift into disagreeing. */} + + + {freshness.kind === 'page' + ? `Updated ${formatSecondsAgo(freshness.seconds)}` + : `Data ${formatSecondsShort(freshness.seconds)} old`} + + {formatSecondsShort(freshness.seconds)} )} diff --git a/src/components/freshness.test.ts b/src/components/freshness.test.ts new file mode 100644 index 0000000..4e69630 --- /dev/null +++ b/src/components/freshness.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { selectFreshness } from './freshness'; + +// The StatsBar has one slot for "how old is this?", but there are two different +// clocks that could fill it, and they disagree exactly when it matters most: +// a dead crawler means the data is hours old while the page itself was fetched +// seconds ago. These tests pin down which clock wins in which state, because the +// tempting "simplification" — always show one of them — is what makes the bar lie. +// See CONTEXT.md for Data Freshness vs Page Freshness. + +describe('selectFreshness', () => { + it('reports page freshness while the crawler is live', () => { + // Healthy case: the data is current, so the only interesting number is + // how long ago this tab last fetched. + expect( + selectFreshness({ live: true, secondsSinceHeartbeat: 12, secondsSincePageUpdate: 8 }) + ).toEqual({ kind: 'page', seconds: 8 }); + }); + + it('reports data freshness once the crawler heartbeat has lapsed', () => { + // The bug this prevents: showing "Updated 8s ago" next to "Updates paused". + // The page is fresh, but it is freshly displaying six-hour-old data. + expect( + selectFreshness({ live: false, secondsSinceHeartbeat: 21600, secondsSincePageUpdate: 8 }) + ).toEqual({ kind: 'data', seconds: 21600 }); + }); + + it('reports nothing when the crawler is down and its age is unknown', () => { + // The maintenance / DB-error response sends live:false with a null + // heartbeat. There is no honest number to show, and the "Maintenance" + // label already says everything true, so the slot is omitted. + expect( + selectFreshness({ live: false, secondsSinceHeartbeat: null, secondsSincePageUpdate: 8 }) + ).toEqual({ kind: 'none' }); + }); + + it('reports nothing before the first fetch completes', () => { + // No poll has returned yet, so neither clock has started. + expect( + selectFreshness({ live: true, secondsSinceHeartbeat: 12, secondsSincePageUpdate: null }) + ).toEqual({ kind: 'none' }); + }); + + it('treats a zero-second age as a real value, not a missing one', () => { + // Guards against a `!seconds` truthiness check creeping in: the very + // first tick after a fetch is legitimately 0 and must still render. + expect( + selectFreshness({ live: true, secondsSinceHeartbeat: 0, secondsSincePageUpdate: 0 }) + ).toEqual({ kind: 'page', seconds: 0 }); + + expect( + selectFreshness({ live: false, secondsSinceHeartbeat: 0, secondsSincePageUpdate: 8 }) + ).toEqual({ kind: 'data', seconds: 0 }); + }); +}); diff --git a/src/components/freshness.ts b/src/components/freshness.ts new file mode 100644 index 0000000..ff5ca7f --- /dev/null +++ b/src/components/freshness.ts @@ -0,0 +1,48 @@ +/** + * Which of the two freshness clocks the StatsBar should show. + * + * `page` — how long ago this tab fetched (Page Freshness). + * `data` — how long ago the crawler last confirmed it was working (Data Freshness). + * `none` — no honest number is available; render nothing. + * + * See CONTEXT.md for the vocabulary. + */ +export type Freshness = + | { kind: 'page'; seconds: number } + | { kind: 'data'; seconds: number } + | { kind: 'none' }; + +interface FreshnessInput { + /** Crawler heartbeat is within its liveness window (`getCrawlerStatus().isRunning`). */ + live: boolean; + /** Server-computed age of the crawler heartbeat; null when unknown (maintenance / DB error). */ + secondsSinceHeartbeat: number | null; + /** Client-computed age of this tab's last fetch; null before the first one lands. */ + secondsSincePageUpdate: number | null; +} + +/** + * One slot, two clocks. While the crawler is live the data is current by + * definition, so the only interesting age is the page's. Once the heartbeat + * lapses that flips: the page keeps refreshing happily, but it is refreshing + * stale data, and continuing to show the page's age would read as reassurance + * ("Updated 8s ago") at precisely the moment the bar should be reporting a + * problem. When the crawler is down *and* its age is unknown there is nothing + * truthful to say, and the "Maintenance" / "Updates paused" label already + * carries the meaning, so the slot is dropped rather than filled with a guess. + */ +export function selectFreshness({ + live, + secondsSinceHeartbeat, + secondsSincePageUpdate, +}: FreshnessInput): Freshness { + if (live) { + return secondsSincePageUpdate === null + ? { kind: 'none' } + : { kind: 'page', seconds: secondsSincePageUpdate }; + } + + return secondsSinceHeartbeat === null + ? { kind: 'none' } + : { kind: 'data', seconds: secondsSinceHeartbeat }; +} diff --git a/src/hooks/useLiveStats.ts b/src/hooks/useLiveStats.ts index 91cf2c9..0a0cc38 100644 --- a/src/hooks/useLiveStats.ts +++ b/src/hooks/useLiveStats.ts @@ -3,7 +3,7 @@ import { useSyncExternalStore } from 'react'; // Single shared poller for /api/live-stats: however many components subscribe -// (StatsBar, FooterStatus, BungieMaintenanceAlert), the tab makes one request per +// (StatsBar, BungieMaintenanceAlert), the tab makes one request per // interval. Polling pauses entirely while the tab is hidden and resumes with an // immediate fetch when it becomes visible again. From 04b2112ff91e6f7ee3023e7c05629c9da71e8c03 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:30:47 -0400 Subject: [PATCH 16/17] add a guard to prevent tests from reaching live DB --- ...03-tests-run-against-a-real-sqlite-file.md | 24 +++++++++ .../0004-mock-only-at-the-network-boundary.md | 6 +++ docs/decisions.md | 50 ++++++++++++++++++- src/lib/db/index.ts | 37 ++++++++++++++ tests/README.md | 5 ++ tests/setup/test-db-path.test.ts | 21 ++++++++ tests/setup/test-db-path.ts | 7 +++ 7 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/setup/test-db-path.test.ts diff --git a/docs/adr/0003-tests-run-against-a-real-sqlite-file.md b/docs/adr/0003-tests-run-against-a-real-sqlite-file.md index 788039e..150a965 100644 --- a/docs/adr/0003-tests-run-against-a-real-sqlite-file.md +++ b/docs/adr/0003-tests-run-against-a-real-sqlite-file.md @@ -33,6 +33,22 @@ The path is set in a Vitest `setupFile` rather than inside a helper, because the test file's own imports run is what lets test files use ordinary static imports instead of `await import()` throughout. +That ordering is load-bearing, and breaking it fails *silently*: `DB_PATH` resolves +to the real data directory, and the suite's `DELETE FROM` and `VACUUM` paths operate +on live data without erroring. So `getDb()` refuses to open anything but the +throwaway path while `VITEST` is set — `tests/setup/test-db-path.ts` publishes the +directory it minted as `DFF_TEST_DB_SENTINEL`, and `assertDbPathAllowed()` in +`src/lib/db/index.ts` requires an exact match. Keyed on `VITEST` rather than on the +sentinel alone so that a suite where the setup file never ran at all — the case where +every other protection has already failed — still refuses rather than going quiet. + +The tempting alternative was to remove the hazard instead of detecting it: have the +setup file `await import('@/lib/db')` immediately after setting the env var, pinning +`DB_PATH` before anything else can. Rejected because its correctness depends on the +import being *dynamic* — a static `import` hoists above the assignment and does +nothing — so a routine tidy-up reverts it, silently, which is the property that made +the original hazard dangerous in the first place. + ## Consequences - Test databases cost a `mkdtemp` plus `initializeSchema()` per test file — @@ -40,6 +56,14 @@ imports instead of `await import()` throughout. overall, well below the value of matching production semantics. - Temp directories leak into the system temp dir if a test process is killed before `afterAll` runs. Harmless, and the OS clears them. +- A misconfigured suite fails on the first `getDb()` call with `Refusing to open …` + rather than quietly using the real database. `tests/setup/test-db-path.test.ts` + pins the invariant, including that `VITEST` is actually set — an inert guard is + the only failure mode here that hides, since one that wrongly refuses breaks + every test file at once. +- `openMaintenanceDb()` is deliberately *not* guarded: nothing in the suite reaches + it today. Its callers (`src/lib/bungie/maintenance.ts`) `VACUUM` through it, so a + test that exercises them should add the check. - The schema under test is the production schema by construction: `getDb()` runs `initializeSchema()`, including the `ended_at` migration guard and the Phase 3 indexes. There is no second schema definition that can drift. diff --git a/docs/adr/0004-mock-only-at-the-network-boundary.md b/docs/adr/0004-mock-only-at-the-network-boundary.md index d3d0d42..418a436 100644 --- a/docs/adr/0004-mock-only-at-the-network-boundary.md +++ b/docs/adr/0004-mock-only-at-the-network-boundary.md @@ -33,6 +33,12 @@ quietly burning Bungie API quota and going flaky against live data. - Test databases are real; see ADR 0003 for why they are files rather than `:memory:`. +- A *configuration guard* in `src/` is not a mock, and this constraint does not + forbid one. `getDb()` refuses to open anything but the throwaway database when + `VITEST` is set (ADR 0003) — it substitutes no behaviour, so it cannot make a + failing test appear to pass; its only effect is aborting a run that is already + misconfigured. It is the sole place `src/` knows tests exist, and it is + deliberate rather than a leak to be tidied away. - Tests that need a specific Bungie response stub `fetch` explicitly. The guard records itself as the original, so the block is restored automatically for the next test with no per-file cleanup. diff --git a/docs/decisions.md b/docs/decisions.md index 7c09c4f..36c2b4c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -186,7 +186,7 @@ invocation, not just on open. Not a correctness bug — but it is why test isola `DATA_DIR` and not merely the database file, since a suite running during a maintenance vacuum would otherwise fail every test with `DatabaseMaintenanceError`. -### CLAUDE.md's raid-detection description is inaccurate +### CLAUDE.md's raid-detection description is inaccurate — fixed 2026-07-29 CLAUDE.md states raid detection "matches `activityHash` against the manifest cache (`data/manifest-cache.json`)". Nothing reads that file. `RAID_DEFINITIONS` is a hardcoded literal @@ -194,6 +194,10 @@ in `src/lib/bungie/manifest.ts:16`; `setup-manifest` only *writes* the cache, fo before hand-editing the literal. Convenient for tests — raid detection is fully hermetic — but the documentation implies a runtime dependency that does not exist. +Corrected in CLAUDE.md on 2026-07-29: the conventions bullet, the code-layout line, and the +"Raid Detection" section now all say the table is static source and that `setup-manifest` alone +changes nothing about detection. + ### The `ended_at` cutover was already complete The brief described it as in-flight and asked for a parity safety net across ~10 SQL sites. It @@ -237,3 +241,47 @@ which is right, since the run itself is real. Note also that `types.ts` declares `UserInfoCard.displayName` and `DestinyPostGameCarnageReportData.startingPhaseIndex` as required, and both are optional in practice. The type is more confident than the API. + +## 2026-07-29 — Guarding the test suite against opening the real database + +`DB_PATH` (`src/lib/db/index.ts:7`) is a module-level const resolved at import time, so the test +suite lands on its throwaway database only because `tests/setup/test-db-path.ts` runs as the first +`setupFile`. Break that ordering and the failure is *silent*: `DB_PATH` becomes the real path and +`resetTestDb()`'s five `DELETE FROM`s, plus every seeded write, hit live data without erroring. + +The tell was an asymmetry. The suite guards the *network* with a real thrower +(`tests/setup/no-network.ts`) and guarded the *database* with nothing but import ordering and a +comment. + +**Decision.** `assertDbPathAllowed()` in `getDb()`: while `VITEST` is set, `DFF_TEST_DB_SENTINEL` +(published by the setup file) must exist and match `DB_PATH` exactly, or the call throws. Recorded +against ADR 0003, whose mechanism this hardens, with a line in ADR 0004 stating that a configuration +guard is not a mock — deliberately, so nobody deletes the `VITEST` branch as a smell. + +### Scope, and what was rejected + +- **`getDb()` only.** `openMaintenanceDb()` opens a second raw connection and its callers `VACUUM` + through it, but nothing in the suite reaches it; left unguarded with a comment saying so. +- **Not the eager-import fix.** Having the setup file `await import('@/lib/db')` to pin `DB_PATH` + removes the hazard rather than detecting it and needs no `src/` change — but it only works as a + *dynamic* import, since a static one hoists above the env assignment. A routine tidy-up reverts it + silently, which is exactly what made the original hazard dangerous. +- **Not sentinel-only keying.** Reads as a general invariant and keeps `src/` innocent of tests, but + goes inert when the sentinel is unset — i.e. when the setup file never ran, the case where + everything else has already failed. +- **Not a guard in `tests/helpers/db.ts`.** `tests/helpers/seed.ts` and + `tests/db/ended-at-derivation.test.ts` import `getDb` directly, so a helper-level check would have + covered the deletes and left the writes open: a partial guard that reads as complete. +- **Not a new ADR.** Cheap to reverse (six deletable lines), so two of the three ADR criteria fail. + +### Blast radius, for the record + +Smaller than it first looks, and it sizes the whole decision. Tests run on dev, where `DB_PATH` +defaults to the 2.5 GB `data/raid-tracker.db` with a live crawler attached. Production is a separate +host that never runs `npm test`; CI has no database at all. Worst case was "wipe the dev DB, +re-crawl", cushioned by the dated snapshots in `data/`. Cheap insurance against an annoying loss, +not disaster prevention. + +Verified by running `getDb()` under `tsx` with `VITEST=true` across four cases — sentinel missing, +sentinel mismatched, sentinel matching, and `VITEST` unset — each against a scratch path so a broken +guard could not touch the real database. First two throw, last two proceed. diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index a8667cf..3624d9d 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -60,7 +60,41 @@ export function isDatabaseMaintenanceError(error: unknown): error is DatabaseMai || (error instanceof Error && error.name === 'DatabaseMaintenanceError'); } +/** + * Refuses to open anything but the suite's throwaway database while running under + * Vitest. + * + * DB_PATH above is resolved at *import* time, so whichever import comes first + * freezes it for the process. Tests win that race only because + * tests/setup/test-db-path.ts runs as a Vitest setupFile, ahead of any test + * file's imports. If that ordering is ever broken — setupFiles reordered, the + * setup file turned into a helper, a config without setupFiles — DB_PATH + * silently becomes the real database and the suite's DELETE/VACUUM paths operate + * on live data. Nothing would error. This turns that into a hard failure. + * + * Yes, this means src/ contains a branch that only exists for tests, which is + * the sort of thing ADR 0004 is otherwise suspicious of. It is a precondition + * check, not a mock: it substitutes no behaviour, so it cannot make a failing + * test pass — its only effect is aborting a run whose configuration is already + * wrong. Rails' ProtectedEnvironmentError makes the same trade. Please don't + * delete it as a smell; see docs/adr/0003 and 0004. + */ +function assertDbPathAllowed(): void { + if (!process.env.VITEST) return; + + const allowed = process.env.DFF_TEST_DB_SENTINEL; + if (!allowed || DB_PATH !== path.resolve(allowed)) { + throw new Error( + `Refusing to open ${DB_PATH} under Vitest — it is not the throwaway database ` + + `minted by tests/setup/test-db-path.ts. Either that setup file did not run, or ` + + `something imported src/lib/db before it did.` + ); + } +} + export function getDb(): Database.Database { + assertDbPathAllowed(); + if (isDbQuiesceActive()) { closeDb(); throw new DatabaseMaintenanceError(); @@ -92,6 +126,9 @@ export function closeDb(): void { } export function openMaintenanceDb(): Database.Database { + // Deliberately NOT behind assertDbPathAllowed(): nothing in the test suite + // reaches this today. If you write a test that does, add the call — this + // opens a raw connection and its callers VACUUM through it. fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); const db = new Database(DB_PATH); configureDatabase(db); diff --git a/tests/README.md b/tests/README.md index 901f2a3..992d61b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -95,6 +95,11 @@ schema. You don't have to set it up — `tests/setup/test-db-path.ts` handles it run. A real file rather than `:memory:` for a specific reason: [ADR 0003](../docs/adr/0003-tests-run-against-a-real-sqlite-file.md). +If you ever see `Refusing to open … under Vitest`, that setup file didn't run before something +imported `src/lib/db` — check the `setupFiles` order in `vitest.config.ts` rather than working +around the error. The guard exists because the alternative is the suite quietly using the real +2.5 GB database. + `seedRun` goes through `insertFullPGCR`, the same chokepoint all four production ingestion sources use — so seeded rows are rows production could actually create. Don't reach for raw `INSERT`s. diff --git a/tests/setup/test-db-path.test.ts b/tests/setup/test-db-path.test.ts new file mode 100644 index 0000000..741217d --- /dev/null +++ b/tests/setup/test-db-path.test.ts @@ -0,0 +1,21 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { DB_PATH } from '@/lib/db'; + +// Tests the invariant that ./test-db-path.ts exists to establish: this process +// is pointed at a throwaway database, not the real one. Both assertions target +// the failure mode that *hides* — a guard that is silently inert. The opposite +// case needs no test: a guard that wrongly refuses breaks every other test file +// at once, loudly. +describe('the test database path', () => { + it('is pointed at a throwaway database, not the real one', () => { + expect(DB_PATH).toBe(path.resolve(process.env.DFF_TEST_DB_SENTINEL!)); + }); + + it('has the condition set that arms the guard in getDb()', () => { + // assertDbPathAllowed() is a no-op when VITEST is unset. If some future + // runner stops setting it, the guard disappears without a sound — so + // assert the trigger itself, not just the path it protects. + expect(process.env.VITEST).toBeTruthy(); + }); +}); diff --git a/tests/setup/test-db-path.ts b/tests/setup/test-db-path.ts index 16ea453..b010a3c 100644 --- a/tests/setup/test-db-path.ts +++ b/tests/setup/test-db-path.ts @@ -27,6 +27,13 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dff-test-')); process.env.RAID_TRACKER_DB_PATH = path.join(dir, 'test.db'); +// The one database this process is allowed to open. getDb() compares DB_PATH +// against this and refuses anything else while VITEST is set, so if the ordering +// described above is ever broken the suite fails loudly instead of quietly +// operating on the real, live database. Set here rather than in a helper because +// this file is the only thing that knows which directory was minted. +process.env.DFF_TEST_DB_SENTINEL = path.join(dir, 'test.db'); + // Keep the suite off any real key even if a test reaches code that reads one. process.env.BUNGIE_API_KEY = 'test-key-not-a-real-credential'; From f4f65ce5466745d14e71f161c004c8627d236225 Mon Sep 17 00:00:00 2001 From: FarmFinder <114182668+agrorithms@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:17:20 -0400 Subject: [PATCH 17/17] documentation updates --- .gitignore | 8 ++- CLAUDE.md | 60 +++++++++++++++++++ .../0005-active-session-loop-resilience.md | 35 +++++++++++ docs/decisions.md | 2 + tests/README.md | 9 +++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 docs/adr/0005-active-session-loop-resilience.md diff --git a/.gitignore b/.gitignore index 4a10d02..1c655b0 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,10 @@ localhost.key certificates #handoff docs -docs/handoffs/ \ No newline at end of file +docs/handoffs/ + +#tickets +docs/tickets/ + +# claude plans and skills +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8ae2ffc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# Destiny Farm Finder + +Real-time Destiny 2 raid completion tracker. SQLite database fed by background crawlers against the Bungie API; Next.js app reads from the DB and serves live leaderboards + active fireteam sessions. + +## Note + +- NEVER overwrite files in ~/.claude/plans/ + +## Commands + +`package.json` has the full list. The non-obvious ones: + +- `npm run start` — **web app only.** The crawler/scanner/discovery are separate PM2 processes (`ecosystem.config.js`) and must be started independently. +- `npm run setup-manifest` — writes `data/manifest-cache.json` for a human to read. Changes nothing about runtime behaviour; see the raid-detection convention below. +- `npm run e2e:maintenance` — slow, spawns real crawler/scanner processes against a mock Bungie server. Deliberately outside `npm test` and CI. + +Scripts run via `tsx` using `tsconfig.scripts.json`. Next.js app and scripts compile separately. + +## Conventions that will burn you if missed + +- **Player identity is `Name#Code`.** Always store and display `bungie_global_display_name` in full. Partial names were a real bug — see recent commits. +- **Raid detection is a hardcoded table, not a runtime cache lookup.** `RAID_DEFINITIONS` in `src/lib/bungie/manifest.ts` is a literal map of raid key → name/slug/activity hashes, flattened into a hash→key `Map` at import time. Nothing reads `data/manifest-cache.json` at runtime. Adding a new raid means **editing `manifest.ts`** — `npm run setup-manifest` only writes the cache file for a human to read. +- **Dedicated scanner key pool** (`BUNGIE_SCANNER_API_KEY`, `_2`) — the scanner rotates only within its own keys, and each key gets its own RPS budget. Don't share scanner keys across other processes. +- **All SQL lives in `src/lib/db/queries.ts`.** API routes call it directly — no ORM, no service layer. +- **API cache headers are only half the story** — Cloudflare cache rules (not in this repo) rewrite them, and a rule with no Browser TTL falls back to a 4h zone default. Check `cf-cache-status` and the header prod actually returns before touching `src/lib/http/cache.ts`. See `docs/decisions.md`. + +## Env vars + +Core: `BUNGIE_API_KEY`, `BUNGIE_SCANNER_API_KEY`, `BUNGIE_SCANNER_API_KEY_2`, `BUNGIE_SCANNER_API_KEY_3`, `BUNGIE_SCANNER_API_KEY_4`, `BUNGIE_DISCOVERY_API_KEY`, `ADMIN_STATS_USERNAME` / `ADMIN_STATS_PASSWORD`, `SEED_PLAYERS`. + +Web: `NEXT_PUBLIC_SITE_URL` (default `https://destinyfarmfinder.qzz.io`) — sets `metadataBase` for social-share unfurls **and** the same-origin allowlist for the client-write guard. `NEXT_PUBLIC_BUNGIE_PUBLIC_API_KEY` — public key used by browser-side Bungie calls (profile + LinkedProfiles resolution). `PAGE_TOKEN_SECRET` (optional, server-only) — when set, enables the short-lived HMAC page-token check on the client-write endpoints (`active-session-update`, `players/identity`, `queue-crawl`); when unset, those endpoints still enforce the same-origin check but skip the token layer. See `src/lib/http/request-auth.ts`. + +Tuning — the scripts below list every knob and its default. These are the ones whose *value* is load-bearing for a reason the code doesn't explain: + +- `SQLITE_BUSY_TIMEOUT_MS` (30000) — how long any process waits on a competing write lock before SQLITE_BUSY. The wait blocks that process's event loop. +- `CRAWLER_MEMBER_RESOLVE_CONCURRENCY` (4) — member resolution runs concurrently rather than one-at-a-time; sequential resolution of the full `CRAWLER_MEMBER_RESOLVE_LIMIT` could run ~25 × fetch-timeout ≈ 12.5 min and trip the poll watchdog on its own. +- `BUNGIE_GAME_SERVER_BACKOFF_SEC` (2) — self-imposed per-key pause when Bungie returns ErrorCode 1672 `DestinyThrottledByGameServer`, which arrives with `ThrottleSeconds: 0`. +- `CRAWLER_CLEANUP_BATCH_SIZE` (500) — expired PGCRs deleted per cleanup transaction, sized to keep each write-lock hold sub-second; one whole-backlog DELETE would hold the write lock for minutes and freeze the crawler's event loop. `CRAWLER_CLEANUP_YIELD_MS` (25) pauses between batches so the session/crawl loops and the scanner interleave. + +Active-session display (`/active-sessions` + the StatsBar/OG count): `ACTIVE_SESSION_DISPLAY_LIMIT` (default 600 — max **fireteams** rendered; counted in fireteams, never rows) and `ACTIVE_SESSION_ROW_SCAN_LIMIT` (default 3000 — max raw per-player rows scanned before dedupe). These are two different units and must stay separate: `active_sessions` is keyed by `membership_id`, so one fireteam yields up to 6 rows, and capping rows silently drops the longest-running raids. See `docs/adr/0001-fireteam-denominated-display-cap.md`. Verify with `npx tsx scripts/verify-active-session-limit.ts` (needs the crawler running — 900s freshness window). + +Active-session poll backoff (players found offline/private are snoozed instead of re-polled every cycle; maintained by `recordSessionCheck` on `players.next_session_eligible_at`): `SESSION_OFFLINE_BACKOFF_BASE_SEC` (default 120), `SESSION_OFFLINE_BACKOFF_CAP_SEC` (default 960 ≈ 16 min), `SESSION_PRIVACY_BACKOFF_SEC` (default 21600 = 6h). + +Full list: `scripts/start-crawler.ts`, `scripts/start-scanner.ts`, `scripts/discover.ts`. + +## Active-session loop resilience + +The active-session poll loop shares an event loop and a singleton Bungie client with the crawl loop, and a single hung request used to park it indefinitely (observed: overnight stalls) while the crawl heartbeat stayed green. Four guards close this off: a per-poll watchdog, a `finally`-anchored reschedule, a completion-anchored `session_heartbeat`, and treating a timeout as distinct from "offline" so a Bungie storm can't delete live fireteams. Don't remove one without reading [ADR 0005](docs/adr/0005-active-session-loop-resilience.md). + +## Backups + +There is **no continuous replication.** Backups are manual snapshots: `npx tsx scripts/backup-db.ts` checkpoints the WAL and `VACUUM INTO`s a dated copy alongside the live DB (`data/raid-tracker.backup-YYYY-MM-DD.db`). It needs ~1 DB size of free disk. Restoring is a file move: stop the processes, swap the snapshot into `data/raid-tracker.db` (remove stale `-wal`/`-shm` files), restart. Crawled data is re-crawlable, which is why this is tolerable. + +## Testing + +Vitest. **`tests/README.md` is the how-to** — read it before writing or changing a test; it covers the two ground rules (mock `fetch` and only `fetch`; `npm test` stays hermetic), the layout, colocate-vs-`tests/`, fixtures-vs-builders, and the naming conventions. The rationale is in [ADR 0003](docs/adr/0003-tests-run-against-a-real-sqlite-file.md) and [ADR 0004](docs/adr/0004-mock-only-at-the-network-boundary.md); the build-out is in `docs/handoffs/testing-framework-handoff.md`. CI runs `npm test` only. + +**Never reorder `setupFiles` in `vitest.config.ts`** — `tests/setup/test-db-path.ts` must stay first or the suite binds to the live dev database and `resetTestDb()` deletes from it. `getDb()` enforces this; `tests/README.md` explains why. + +Application code was not changed to make anything testable — if a test seems to require that, question it first. diff --git a/docs/adr/0005-active-session-loop-resilience.md b/docs/adr/0005-active-session-loop-resilience.md new file mode 100644 index 0000000..6a017b7 --- /dev/null +++ b/docs/adr/0005-active-session-loop-resilience.md @@ -0,0 +1,35 @@ +# The active-session loop can't silently die + +The crawl loop and the active-session poll loop run on the same event loop and share **one** +singleton Bungie client / rate limiter (`getBungieClient()`). The session loop reschedules itself +only *after* a poll returns, so a single Bungie request that hangs past its `AbortSignal.timeout` +(`BUNGIE_FETCH_TIMEOUT_MS`, default 30s) — a rare escape, but it happens — used to park the whole +session loop **indefinitely** (observed: overnight stalls) while the cheaper PGCR crawl cycles kept +looping and its heartbeat stayed green. + +Four guards close this off (`src/lib/crawler/index.ts` `activeSessionLoop`, +`src/lib/crawler/active-sessions.ts`): + +- **Per-poll watchdog** (`ACTIVE_SESSION_POLL_WATCHDOG_MS`, default 600000 = 10 min). The whole + poll body races a timer; on expiry we log, bump `session_watchdog_trips`, and reschedule. + **Option A (abandon-in-place):** `Promise.race` does *not* cancel the losing promise, so the one + hung request keeps its socket and leaks until process restart — acceptable at ~1 socket per rare + event. **Future Option B (not yet done):** thread an `AbortController` (`AbortSignal.any`) + through `BungieClient.request()` so the watchdog actually tears the hung request down instead of + leaking it — deferred because it changes `request()` for the PGCR crawler and scanner too. +- **Bulletproof reschedule.** `setTimeout(activeSessionLoop, …)` lives in a `finally` (guarded by + `shouldStop`/enable), and the Bungie-maintenance wait sits *inside* the try — so nothing thrown + in a poll (watchdog, DB error, or the maintenance wait itself) can skip the reschedule. The + `SystemDisabled` catch is record-only; the single maintenance-wait at the top of the next + iteration does the actual blocking. +- **Completion-anchored heartbeat.** `crawler_state.session_heartbeat` is written only when a poll + *completes* within budget (separate from the crawl-loop `heartbeat`, which stays fresh even when + the session loop is dead). Surfaced on the admin stats page and factored into the `/api/status` + health verdict — `SESSION_HEARTBEAT_STALE_SEC` (default 900 = 15 min) is when `/api/status` + reports `degraded`. +- **Timeout ≠ offline.** `checkPlayerActivityDetailed` returns a distinct `error` status for a + timeout/5xx (vs a *clean* `inactive`). During a Bungie storm this stops the re-verify from + **deleting live fireteams** (no positive "raid ended" confirmation → keep the row; the + `MAX_ACTIVE_SESSION_AGE_SECONDS` force-delete is the backstop) and stops the candidate poll from + **benching live raiders** on offline backoff. It also skips the doomed teammate probes on a + failed anchor, cutting wasted storm API calls. diff --git a/docs/decisions.md b/docs/decisions.md index 7c09c4f..fd776b2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -194,6 +194,8 @@ in `src/lib/bungie/manifest.ts:16`; `setup-manifest` only *writes* the cache, fo before hand-editing the literal. Convenient for tests — raid detection is fully hermetic — but the documentation implies a runtime dependency that does not exist. +7/29/26 This has been addressed + ### The `ended_at` cutover was already complete The brief described it as in-flight and asked for a parity safety net across ~10 SQL sites. It diff --git a/tests/README.md b/tests/README.md index 901f2a3..b868a35 100644 --- a/tests/README.md +++ b/tests/README.md @@ -95,6 +95,15 @@ schema. You don't have to set it up — `tests/setup/test-db-path.ts` handles it run. A real file rather than `:memory:` for a specific reason: [ADR 0003](../docs/adr/0003-tests-run-against-a-real-sqlite-file.md). +**`test-db-path.ts` must stay first in `setupFiles`, and `getDb()` enforces it.** `DB_PATH` in +`src/lib/db/index.ts` is a module-level const resolved at *import* time, so if anything imports the +db module before `test-db-path.ts` has pointed `DB_PATH` at the throwaway file, the whole suite +binds to the real database — and `resetTestDb()`'s `DELETE FROM`s land on your live dev data. +Don't reorder `setupFiles` in `vitest.config.ts` and don't convert `test-db-path.ts` into a helper +that some test imports. `getDb()` refuses to open anything but the throwaway DB while `VITEST` is +set, so a break fails loudly with `Refusing to open …` rather than silently destroying data. +`openMaintenanceDb()` is deliberately left unguarded — see ADR 0003 for why. + `seedRun` goes through `insertFullPGCR`, the same chokepoint all four production ingestion sources use — so seeded rows are rows production could actually create. Don't reach for raw `INSERT`s.