Skip to content

Commit fd1b7f1

Browse files
Pantheon 2.0: version-centric manifest and query-time difficultyTier (#133)
* Replace gauntlet manifest fields with version-centric pantheon lists. Expose pantheonVersionIds and pantheonSunsetVersionIds from /manifest. Co-authored-by: Cursor <cursoragent@cursor.com> * Support legacy thepantheon activity path in manifest helpers. Sunset Pantheon versions remain associated with activity 101 (path thepantheon) in production, so lookups and manifest splits must accept both paths. Co-authored-by: Cursor <cursoragent@cursor.com> * Use pantheon path only, aligned with Services seeds. Both sunset (101) and active (102) Pantheon activities use path pantheon in Services; drop thepantheon legacy handling. Co-authored-by: Cursor <cursoragent@cursor.com> * Make difficultyTier nullable on instance API responses Align OpenAPI with nullable DB column; null for legacy runs and non-tier activities. Co-authored-by: Cursor <cursoragent@cursor.com> * Compute difficultyTier at query time from skull_hashes Look up feat skulls from activity_feat_definition per request instead of reading a stored column or using a startup cache. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix CI: null skull hashes, format, and legacy thepantheon paths Handle null skull_hashes in difficulty tier resolution, restore pantheon path compatibility for production sunset activity, and update tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Use pantheon path only; drop thepantheon legacy support Align manifest helpers and OpenAPI examples with Services seeds where activity 101 and 102 both use path pantheon. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Prettier formatting on difficulty tier and pantheon files Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 427e7a4 commit fd1b7f1

13 files changed

Lines changed: 376 additions & 68 deletions

File tree

open-api/openapi.json

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@
218218
},
219219
"required": ["issues"]
220220
},
221+
"DifficultyTier": {
222+
"type": "string",
223+
"enum": ["adventure", "standard", "custom"],
224+
"description": "MotT difficulty tier derived at read time from instance skull_hashes and activity_feat_definition. Adventure uses fixed skulls; Custom has selected feats; Standard is Custom with no feats."
225+
},
221226
"DestinyMembershipType": {
222227
"type": "integer",
223228
"enum": [0, 1, 2, 3, 4, 5, 6, -1],
@@ -262,6 +267,10 @@
262267
"format": "uint32"
263268
}
264269
},
270+
"difficultyTier": {
271+
"nullable": true,
272+
"$ref": "#/components/schemas/DifficultyTier"
273+
},
265274
"score": {
266275
"type": "integer",
267276
"minimum": 0
@@ -330,6 +339,7 @@
330339
"fresh",
331340
"playerCount",
332341
"skullHashes",
342+
"difficultyTier",
333343
"score",
334344
"dateStarted",
335345
"dateCompleted",
@@ -382,6 +392,10 @@
382392
"format": "uint32"
383393
}
384394
},
395+
"difficultyTier": {
396+
"nullable": true,
397+
"$ref": "#/components/schemas/DifficultyTier"
398+
},
385399
"score": {
386400
"type": "integer",
387401
"minimum": 0
@@ -428,6 +442,7 @@
428442
"fresh",
429443
"playerCount",
430444
"skullHashes",
445+
"difficultyTier",
431446
"score",
432447
"dateStarted",
433448
"dateCompleted",
@@ -1373,7 +1388,7 @@
13731388
{
13741389
"id": 101,
13751390
"name": "The Pantheon",
1376-
"path": "thepantheon",
1391+
"path": "pantheon",
13771392
"isSunset": true,
13781393
"isRaid": false,
13791394
"releaseDate": "2024-04-30T17:00:00.000Z",
@@ -2241,14 +2256,23 @@
22412256
"minItems": 1,
22422257
"description": "The list of activityId for Pantheon"
22432258
},
2244-
"gauntletVersionIds": {
2259+
"pantheonVersionIds": {
2260+
"type": "array",
2261+
"items": {
2262+
"type": "integer",
2263+
"minimum": 0,
2264+
"exclusiveMinimum": true
2265+
},
2266+
"description": "Active Pantheon versionId values in display order"
2267+
},
2268+
"pantheonSunsetVersionIds": {
22452269
"type": "array",
22462270
"items": {
22472271
"type": "integer",
22482272
"minimum": 0,
22492273
"exclusiveMinimum": true
22502274
},
2251-
"description": "The list of versionId for Pantheon gauntlet modes (full boss lineup)"
2275+
"description": "Sunset Pantheon versionId values in display order"
22522276
},
22532277
"versionsForActivity": {
22542278
"type": "object",
@@ -2313,7 +2337,8 @@
23132337
"resprisedRaidIds",
23142338
"resprisedChallengeVersionIds",
23152339
"pantheonIds",
2316-
"gauntletVersionIds",
2340+
"pantheonVersionIds",
2341+
"pantheonSunsetVersionIds",
23172342
"versionsForActivity",
23182343
"rankingTiers",
23192344
"feats",

src/routes/manifest.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import {
1212
listVersionDefinitions
1313
} from "@/services/manifest/definitions"
1414
import {
15-
getGauntletVersionIds,
1615
getPantheonActivityIds,
16+
getPantheonVersionIds,
1717
sortPantheonActivityIds
1818
} from "@/services/manifest/pantheon"
1919
import { TierBreaks } from "@/services/manifest/tiers"
@@ -92,9 +92,11 @@ export const manifestRoute = new RaidHubRoute({
9292
description: "The list of activityId for Pantheon"
9393
})
9494
.min(1),
95-
gauntletVersionIds: z.array(zNaturalNumber()).openapi({
96-
description:
97-
"The list of versionId for Pantheon gauntlet modes (full boss lineup)"
95+
pantheonVersionIds: z.array(zNaturalNumber()).openapi({
96+
description: "Active Pantheon versionId values in display order"
97+
}),
98+
pantheonSunsetVersionIds: z.array(zNaturalNumber()).openapi({
99+
description: "Sunset Pantheon versionId values in display order"
98100
}),
99101
versionsForActivity: z.record(z.array(zNaturalNumber()).min(1)).openapi({
100102
description: "The set of versionId for each activityId"
@@ -130,7 +132,11 @@ export const manifestRoute = new RaidHubRoute({
130132
])
131133
const raids = activities.filter(a => a.isRaid)
132134
const pantheonIds = sortPantheonActivityIds(activities, getPantheonActivityIds(activities))
133-
const gauntletVersionIds = getGauntletVersionIds(versions, pantheonIds)
135+
const { pantheonVersionIds, pantheonSunsetVersionIds } = getPantheonVersionIds(
136+
versions,
137+
pantheonIds,
138+
activities
139+
)
134140
const versionsSetForActivity: Record<number, Set<number>> = {}
135141
for (const { activityId, versionId } of hashes) {
136142
if (!versionsSetForActivity[activityId]) {
@@ -177,7 +183,8 @@ export const manifestRoute = new RaidHubRoute({
177183
.map(a => a.associatedActivityId!),
178184
resprisedChallengeVersionIds: versions.filter(v => v.isChallengeMode).map(v => v.id),
179185
pantheonIds,
180-
gauntletVersionIds,
186+
pantheonVersionIds,
187+
pantheonSunsetVersionIds,
181188
versionsForActivity: versionsForActivity,
182189
rankingTiers: TierBreaks,
183190
feats: feats,

src/schema/components/ActivityDefinition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const zActivityDefinition = registry.register(
3939
{
4040
id: 101,
4141
name: "The Pantheon",
42-
path: "thepantheon",
42+
path: "pantheon",
4343
isSunset: true,
4444
isRaid: false,
4545
releaseDate: new Date("2024-04-30T17:00:00Z"),

src/schema/components/Instance.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { zDestinyMembershipType } from "@/schema/enums/DestinyMembershipType"
2+
import { zDifficultyTier } from "@/schema/enums/DifficultyTier"
23
import { zInt64, zISO8601DateString, zNaturalNumber, zUInt32, zWholeNumber } from "@/schema/output"
34
import { registry } from "@/schema/registry"
45
import { z } from "zod"
@@ -15,6 +16,7 @@ const zInstancePrimitive = z.object({
1516
description: "Mapped to a FeatDefinition by its skullHash"
1617
})
1718
),
19+
difficultyTier: zDifficultyTier.nullable(),
1820
score: zWholeNumber(),
1921
dateStarted: zISO8601DateString(),
2022
dateCompleted: zISO8601DateString(),

src/schema/enums/DifficultyTier.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { registry } from "@/schema/registry"
2+
import { z } from "zod"
3+
4+
export enum DifficultyTier {
5+
Adventure = "adventure",
6+
Standard = "standard",
7+
Custom = "custom"
8+
}
9+
10+
export type DifficultyTierValue = z.infer<typeof zDifficultyTier>
11+
export const zDifficultyTier = registry.register(
12+
"DifficultyTier",
13+
z.nativeEnum(DifficultyTier).openapi({
14+
description:
15+
"MotT difficulty tier derived at read time from instance skull_hashes and activity_feat_definition. " +
16+
"Adventure uses fixed skulls; Custom has selected feats; Standard is Custom with no feats."
17+
})
18+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { DifficultyTier } from "@/schema/enums/DifficultyTier"
2+
import { describe, expect, it } from "bun:test"
3+
import { classifyDifficultyTier } from "./classify"
4+
5+
const EMPTY_FEAT = 790421403
6+
const PHASE_LIMIT = 251257575
7+
const CUTTHROAT = 2673088233
8+
const TOKEN_LIMIT = 3123804375
9+
const ADVENTURE = 845104503
10+
11+
describe("classifyDifficultyTier", () => {
12+
const knownFeats = new Set([PHASE_LIMIT, CUTTHROAT, TOKEN_LIMIT])
13+
14+
it("returns adventure when adventure skull is present", () => {
15+
expect(classifyDifficultyTier([ADVENTURE], knownFeats, true)).toBe(DifficultyTier.Adventure)
16+
})
17+
18+
it("returns standard for featured custom with only empty feat", () => {
19+
expect(classifyDifficultyTier([EMPTY_FEAT], knownFeats, true)).toBe(DifficultyTier.Standard)
20+
})
21+
22+
it("returns custom for featured custom with a selected feat", () => {
23+
expect(classifyDifficultyTier([EMPTY_FEAT, TOKEN_LIMIT], knownFeats, true)).toBe(
24+
DifficultyTier.Custom
25+
)
26+
})
27+
28+
it("returns standard for customize-portal baseline feats", () => {
29+
expect(classifyDifficultyTier([EMPTY_FEAT, PHASE_LIMIT, CUTTHROAT], knownFeats, true)).toBe(
30+
DifficultyTier.Standard
31+
)
32+
})
33+
34+
it("returns custom for customize-portal with an extra feat", () => {
35+
expect(
36+
classifyDifficultyTier(
37+
[EMPTY_FEAT, PHASE_LIMIT, CUTTHROAT, TOKEN_LIMIT],
38+
knownFeats,
39+
true
40+
)
41+
).toBe(DifficultyTier.Custom)
42+
})
43+
44+
it("returns standard for featured pantheon with no tier skulls", () => {
45+
expect(classifyDifficultyTier([], knownFeats, true)).toBe(DifficultyTier.Standard)
46+
})
47+
48+
it("returns null for non-tier activities", () => {
49+
expect(classifyDifficultyTier([], knownFeats, false)).toBeNull()
50+
})
51+
52+
it("handles null skull hashes", () => {
53+
expect(classifyDifficultyTier(null, knownFeats, false)).toBeNull()
54+
expect(classifyDifficultyTier(null, knownFeats, true)).toBe(DifficultyTier.Standard)
55+
})
56+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { DifficultyTier } from "@/schema/enums/DifficultyTier"
2+
3+
const SKULL_ADVENTURE_TIME_ON_YOUR_SIDE = 845104503
4+
const SKULL_ADVENTURE_FIXED = 2008962334
5+
const SKULL_EMPTY_FEAT = 790421403
6+
7+
export function dedupeSkullHashes(skullHashes: readonly number[] | null | undefined): number[] {
8+
if (!skullHashes?.length) {
9+
return []
10+
}
11+
const seen = new Set<number>()
12+
const out: number[] = []
13+
for (const skull of skullHashes) {
14+
if (skull === 0 || seen.has(skull)) {
15+
continue
16+
}
17+
seen.add(skull)
18+
out.push(skull)
19+
}
20+
return out
21+
}
22+
23+
/** Classify difficulty tier from deduplicated PGCR skulls and feat skulls resolved at query time. */
24+
export function classifyDifficultyTier(
25+
skullHashes: readonly number[] | null | undefined,
26+
knownFeatSkulls: ReadonlySet<number>,
27+
activityHasTierCollection: boolean
28+
): DifficultyTier | null {
29+
const unique = dedupeSkullHashes(skullHashes)
30+
31+
if (
32+
unique.includes(SKULL_ADVENTURE_TIME_ON_YOUR_SIDE) ||
33+
unique.includes(SKULL_ADVENTURE_FIXED)
34+
) {
35+
return DifficultyTier.Adventure
36+
}
37+
38+
const hasEmptyFeat = unique.includes(SKULL_EMPTY_FEAT)
39+
const optionalSelected = unique.filter(
40+
skull => skull !== SKULL_EMPTY_FEAT && knownFeatSkulls.has(skull)
41+
)
42+
43+
if (!activityHasTierCollection && !hasEmptyFeat && optionalSelected.length === 0) {
44+
return null
45+
}
46+
47+
if (optionalSelected.length === 0) {
48+
return DifficultyTier.Standard
49+
}
50+
51+
// Customize-portal launches always report two default optional feats in PGCR skulls.
52+
if (hasEmptyFeat && optionalSelected.length >= 2) {
53+
return optionalSelected.length > 2 ? DifficultyTier.Custom : DifficultyTier.Standard
54+
}
55+
56+
return DifficultyTier.Custom
57+
}
58+
59+
export function activityHasTierCollection(activityId: number): boolean {
60+
return activityId === 101 || activityId === 102
61+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { pgReader } from "@/integrations/postgres"
2+
import { DifficultyTier } from "@/schema/enums/DifficultyTier"
3+
import { activityHasTierCollection, classifyDifficultyTier } from "./classify"
4+
5+
async function loadKnownFeatSkulls(
6+
skullHashes: readonly number[] | null | undefined
7+
): Promise<Set<number>> {
8+
const unique = [...new Set((skullHashes ?? []).filter(skull => skull !== 0))]
9+
if (unique.length === 0) {
10+
return new Set()
11+
}
12+
13+
const rows = await pgReader.queryRows<{ skullHash: number }>(
14+
`SELECT skull_hash::int AS "skullHash"
15+
FROM activity_feat_definition
16+
WHERE skull_hash = ANY($1::bigint[])`,
17+
{ params: [unique] }
18+
)
19+
20+
return new Set(rows.map(row => row.skullHash))
21+
}
22+
23+
type InstanceWithSkulls = {
24+
skullHashes: readonly number[] | null | undefined
25+
activityId: number
26+
}
27+
28+
export async function resolveDifficultyTier(
29+
instance: InstanceWithSkulls
30+
): Promise<DifficultyTier | null> {
31+
const knownFeatSkulls = await loadKnownFeatSkulls(instance.skullHashes)
32+
return classifyDifficultyTier(
33+
instance.skullHashes,
34+
knownFeatSkulls,
35+
activityHasTierCollection(instance.activityId)
36+
)
37+
}
38+
39+
export async function attachDifficultyTier<T extends InstanceWithSkulls>(
40+
instance: T
41+
): Promise<T & { difficultyTier: DifficultyTier | null }> {
42+
const [withTier] = await attachDifficultyTiers([instance])
43+
return withTier
44+
}
45+
46+
export async function attachDifficultyTiers<T extends InstanceWithSkulls>(
47+
instances: T[]
48+
): Promise<(T & { difficultyTier: DifficultyTier | null })[]> {
49+
if (instances.length === 0) {
50+
return []
51+
}
52+
53+
const allSkulls = instances.flatMap(instance => instance.skullHashes ?? [])
54+
const knownFeatSkulls = await loadKnownFeatSkulls(allSkulls)
55+
56+
return instances.map(instance => ({
57+
...instance,
58+
difficultyTier: classifyDifficultyTier(
59+
instance.skullHashes,
60+
knownFeatSkulls,
61+
activityHasTierCollection(instance.activityId)
62+
)
63+
}))
64+
}

0 commit comments

Comments
 (0)