Skip to content

Repository files navigation

fireteam report

CI License: MIT

Live: https://keivanmalhani.github.io/fireteam-report/

Compare a Destiny 2 fireteam's raid and dungeon clears and get a ranked list of what to run tonight.

What it is for

Raid Report tells you what you did. This tells you what to run tonight.

Every other Destiny stats site is built around one player. You look up your own name, you see your own clears, and that is the end of it. That does not answer the question a group of friends actually asks on a Tuesday evening, which is "what should we run, given who showed up". Answering that needs everybody's numbers side by side, and then it needs an opinion about them.

Get two to six players in, by signing in, by loading a clan roster, or by typing Bungie Names, and you get:

  • one sentence saying what to run tonight, before any table or number
  • the top pick as a card of its own, then the runners up in order, each with one sentence saying why
  • a matrix of who has cleared what, folded away under the answer rather than in front of it
  • per player totals, distinct raids cleared, and the activity they run most
  • a link that loads the same fireteam for anyone you paste it to
  • a plain text summary sized for a Discord message
  • a 1200x630 share card of the top pick, which is also the site's og:image

The recommendations

The ranking is the point. It is an opinion, not a sort:

Rule Fires when Why it ranks there
Sherpa run Exactly one player has no clears, everyone else has at least one Best thing a full fireteam can do with an evening. The site names the player.
Everyone's first Nobody in the fireteam has cleared it Nobody is bored and nobody is being carried.
Speedrun Every player has five or more clears Fast, clean, low friction.
Rusty Everyone has cleared it, but the fireteam total is low A warning. Expect it to be slow.
Lopsided One player has more clears than everyone else combined A warning. That player will end up calling the whole thing.

Sherpa runs rank first, then everyone's first, then speedruns. Rusty and lopsided sit below those because they are warnings, not suggestions. The site says this on the page too, so the order is not a black box.

The displayed list will not show the same verdict more than twice in a row; it pulls the next different verdict up instead. A fireteam runs one thing tonight, so the fourth best sherpa run tells you almost nothing the first one did not, while the best speedrun tells you something new. This is display order only, kept in a separate function from the ranking: it never changes which activities are recommended or what verdict each one gets, and the order inside a verdict is preserved.

The engine is a pure function in src/recommend.ts. It takes a matrix of numbers and returns ranked recommendations. It does no fetching, so it is tested directly against fixture matrices, including the cases where each rule must not fire.

The page is arranged so the answer arrives before the evidence. The first thing on it is one sentence -- "Run Vault of Glass and get Ana their first clear." -- generated by headline() from the same ranked list everything else uses. Under it is the top pick as a single large card, then the runners up, then the ranking rules behind a disclosure, then the matrix behind another one. Somebody who has never seen the site should be able to read the first line and leave. Nothing was deleted to get there; the grid, the tally and the explanation are all still on the page, one click down.

Why it does not go stale

The list of raids and dungeons is not hardcoded. On each visit the site reads Bungie's manifest, pulls the activity definitions, and keeps everything tagged with activity mode 4 (raid) or 82 (dungeon).

That matters because Destiny ships new raids. A hardcoded list needs a commit and a deploy every time; a derived list picks up a new raid the moment Bungie publishes the manifest entry, with no work from anyone.

The derived list is cached in localStorage against the manifest version string, so the definition file is downloaded once and then not again until Bungie publishes a new version. A snapshot is committed in src/fallback-activities.ts and used only when bungie.net cannot be reached, so the page still renders when the API is down. A test re-derives that snapshot from committed raw definitions, so it cannot drift away from the collapsing rules without CI noticing.

Bungie ships one definition per difficulty, so a single raid arrives as "Vault of Glass: Standard", "Vault of Glass: Master" and "Vault of Glass: Challenge Mode". Those collapse into one row with the tiers tracked underneath. The suffixes stripped are Standard, Normal, Master, Legend, Expert, Contest, Prestige, Challenge Mode and Level NN. Pantheon is grouped separately, because it carries the raid mode flag but is a boss rush rather than a raid. Bungie tags Crota's End as both a raid and a dungeon; raid wins, so it appears once.

As of manifest version 244213.26.06.29.2000-1-bnet.65583 that is 37 raid names and 21 dungeon names collapsing into 14 raids, 9 dungeons and 4 Pantheon encounters.

Getting a fireteam in

The site opens in demo mode against a committed fixture fireteam, so the whole thing works before you have done anything at all. There are three ways to point it at real people, and none of them asks anybody to create anything.

Load a clan. One box, one exact clan name, and then a roster you tick people off. This needs no sign-in and no key of your own: clan reads are public. The people who are online right now sort to the top and the first six are ticked already, because that is usually the answer.

Bungie matches the whole clan name, not part of it. Capitals and surrounding spaces are forgiven and spelling is not, so "Math Clas" finds nothing. The site says so where you type it.

Sign in with Bungie. One button. It goes to bungie.net, comes back through d2-auth, and then "Add me" puts you in the fireteam with no name to type, and "Use my clan" skips knowing the clan name. That is all it buys: every stats call this site makes works signed out. Bungie issues no refresh token to a public client, so the session lasts an hour and cannot be extended, only replaced; the page says how much of it is left and offers the button again when it runs out.

Type Bungie Names. Two to six of them, Guardian#1234. No sign-in, because building a fireteam out of your friends means naming people who are not you.

The site's own Bungie API key ships in the built JavaScript. That is not an oversight: a browser has to send one with every request, so there is nowhere to put it that a reader cannot reach, and every static Destiny tool works this way. The only thing it protects is a rate limit, which is therefore shared between everyone using the site. That is why the picker caps at a fireteam rather than offering to read a hundred people, why players are read three at a time instead of all at once, and why the panel tells you what a load will cost before you press the button.

Reading one player is one profile call plus one per character, so two to four requests; a six person report is twelve to twenty four. Somebody picked off a roster costs one less than somebody typed in, because the roster already knows which membership they are and the name search can be skipped.

localStorage holds one thing: the cached activity list. The session, when there is one, lives in sessionStorage and is written by d2-auth, not by this site.

What it will not do

  • It will not read private accounts. If someone has their Destiny privacy set so stats are hidden, the site says so in their column instead of showing a zero, because "no clears" and "would not say" are different answers.
  • It will not tell you what is currently in the game. The manifest still contains sunset content such as Leviathan and Scourge of the Past. They show up as rows. There is no reliable "currently available" flag in the activity definitions, so filtering them would mean hardcoding a list, which is exactly what the manifest approach avoids.
  • It does not know about anything except raids and dungeons. No strikes, no Crucible, no Trials, no seasonal activities.
  • It does not know when you cleared something. The aggregate stats endpoint gives totals, not dates, so it cannot tell a clear from last night from one in 2018. "Rusty" is inferred from low totals, not from time since last run.
  • It does not check whether you actually have the DLC that an activity belongs to.
  • It will not read a clan you cannot name. Bungie's clan search matches the whole name only, so there is no browsing and no "clans like this". Signing in is the way round that, and it is one click.
  • It has no accounts and stores nothing of its own. Close the tab and the only trace is the cached activity list in your own browser, plus a session that dies with the tab if you signed in. Share links carry the fireteam in the URL hash, which browsers do not send to servers.
  • It is not affiliated with Bungie.

Running it

npm ci
npm test          # vitest
npm run build     # typecheck and bundle to dist/
npm run dev       # local dev server
npm run derive    # refetch the manifest and regenerate the fallback table
npm run ascii     # fail on any non-ascii byte in a text file

npm run derive fetches the live manifest, rewrites src/fallback-activities.ts and fixtures/activity-defs.json, and prints the counts it derived. Run it when Bungie ships a new raid if you want the committed snapshot to match.

The share card is rendered to public/og.png by scripts/render-og.mjs, which is deliberately outside the build because it needs a native canvas:

npm install --no-save @napi-rs/canvas
node scripts/render-og.mjs

Layout

src/
  activities.ts          collapsing variants into one row per activity (pure)
  recommend.ts           the engine, and the one sentence version (pure)
  permalink.ts           fireteam to and from the URL hash (pure)
  bungiename.ts          Name#1234 parsing (pure)
  aggregate.ts           summing stats across characters (pure)
  clan.ts                roster paging, picking, and what it costs (pure)
  signin.ts              the session as UI state and error copy (pure)
  discord.ts             the copy for Discord text (pure)
  card.ts                the 1200x630 share card, layout and drawing (pure layout)
  manifest.ts            manifest fetch, version keyed cache, fallback
  bungie.ts              API client, clan endpoints, per player lookup
  auth.ts                vendored from d2-auth, do not edit here
  fallback-activities.ts generated snapshot, do not edit by hand
  demo.ts                demo mode
  ui/                    DOM rendering, including the clan picker
  main.ts                entry point
fixtures/
  activity-defs.json     real raid and dungeon definitions, for the tests
  demo.json              the six player demo fireteam
tests/                   vitest suite
public/
  og.png                 the demo fireteam's card, rendered by hand
scripts/                 manifest derivation, demo generation, og image

The pure modules hold everything worth testing and none of them import fetch.

Notes on the Bungie API

  • GET /Platform/Destiny2/Manifest/ usually answers without an API key, but not always. It intermittently returns HTTP 500 with ApiKeyMissingFromRequest, so the site retries, attaches the key to every request, and falls back to the committed snapshot if it still fails.
  • The activity definition file it points at is served with access-control-allow-origin: * and needs no key at all.
  • Per player endpoints do need X-API-Key. Bungie reflects the request origin in access-control-allow-origin and allows X-API-Key in access-control-allow-headers, which is what lets a static site on github.io call the API directly with no backend.
  • Clan reads need the key and no OAuth scope whatsoever, which is measured, not assumed. POST /GroupV2/Search/ with {"name":...,"groupType":1,"creationDate":0,"sortBy":0,"page":0} finds a clan by its exact name, GET /GroupV2/{groupId}/Members/?currentpage=1 returns the roster and GET /GroupV2/User/{type}/{id}/0/1/ returns the clans one player is in. All three answer ErrorCode: 1 with nobody signed in.
  • currentpage is 1-based and the roster page holds 100. A Destiny clan caps at 100, so a correct roster is normally one request, but the walk follows hasMore and totalResults anyway rather than assuming that.
  • Bungie returns ordinary application errors as HTTP 500 with a real ErrorCode in the body. A private account, a clan that does not exist and an expired token all arrive as 500. Any retry rule written as status >= 500 therefore retries every one of them, four times over, for the same answer. isRetriable() branches on the code and only lets the status decide when there is no code at all. The four codes that mean the sign-in is over, 99, 2111, 2123 and 2124, are never retried.
  • A player picked off a clan roster arrives with their membership already resolved, so SearchDestinyPlayerByBungieName is skipped for them. That is one request saved per player.
  • A Destiny account holds up to three characters and aggregate stats are reported per character, so clears are summed across all of them.
  • Every request has a deadline and the retry loop has an overall budget. A refused connection fails fast, but a connection that is accepted and then never answered would otherwise hang forever and never reach the fallback, which leaves the page on a spinner with no way out. Captive portals and flaky mobile connections do exactly that.

Security

See SECURITY.md. Short version: report privately through the Security tab, there is no server and no key of its own, and the most recent tagged release is what is supported.

Licence

MIT. See LICENSE.

About

Destiny 2 fireteam planner. Raid Report tells you what you did; this tells your whole fireteam what to run tonight

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages