Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 79 additions & 3 deletions open-api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@
},
"isDayOne": {
"type": "boolean",
"description": "If the instance was completed before the day one end date"
"description": "If the instance was completed within 24 hours of release. For pantheon modes, uses the version release date (release_date_override when set); otherwise uses the activity day_one_end."
},
"isContest": {
"type": "boolean",
Expand All @@ -326,6 +326,10 @@
"type": "boolean",
"description": "If the instance was completed before the week one end date"
},
"isPantheon": {
"type": "boolean",
"description": "If the instance is a pantheon activity mode"
},
"isBlacklisted": {
"type": "boolean",
"description": "If the instance is blacklisted from leaderboards"
Expand All @@ -351,6 +355,7 @@
"isDayOne",
"isContest",
"isWeekOne",
"isPantheon",
"isBlacklisted"
],
"additionalProperties": false
Expand Down Expand Up @@ -1727,6 +1732,9 @@
},
"isChallengeMode": {
"type": "boolean"
},
"isGauntletRace": {
"type": "boolean"
}
},
"required": [
Expand All @@ -1740,6 +1748,49 @@
"isChallengeMode"
]
},
"GauntletRaceEntry": {
"type": "object",
"properties": {
"instanceId": {
"type": "string",
"format": "int64"
},
"rank": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
},
"versionId": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
}
},
"required": ["instanceId", "rank", "versionId"]
},
"PantheonVersionFirstEntry": {
"type": "object",
"properties": {
"versionId": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
},
"instanceId": {
"type": "string",
"format": "int64"
},
"rank": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
},
"isDayOne": {
"type": "boolean"
}
},
"required": ["versionId", "instanceId", "rank", "isDayOne"]
},
"Teammate": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -2546,9 +2597,26 @@
"nullable": true,
"$ref": "#/components/schemas/WorldFirstEntry"
}
},
"gauntletRaceEntry": {
"nullable": true,
"$ref": "#/components/schemas/GauntletRaceEntry"
},
"pantheonVersionFirstEntries": {
"type": "object",
"additionalProperties": {
"nullable": true,
"$ref": "#/components/schemas/PantheonVersionFirstEntry"
}
}
},
"required": ["playerInfo", "stats", "worldFirstEntries"]
"required": [
"playerInfo",
"stats",
"worldFirstEntries",
"gauntletRaceEntry",
"pantheonVersionFirstEntries"
]
},
"PlayerTeammatesResponse": {
"type": "array",
Expand Down Expand Up @@ -2606,6 +2674,9 @@
"minimum": 0,
"exclusiveMinimum": true
},
"isGauntletRace": {
"type": "boolean"
},
"metadata": {
"$ref": "#/components/schemas/InstanceMetadata"
},
Expand All @@ -2616,7 +2687,12 @@
}
}
},
"required": ["leaderboardRank", "metadata", "players"],
"required": [
"leaderboardRank",
"isGauntletRace",
"metadata",
"players"
],
"additionalProperties": false
}
]
Expand Down
18 changes: 16 additions & 2 deletions src/routes/player/membershipId/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import { zBigIntString } from "@/schema/input"
import { zInt64 } from "@/schema/output"
import {
getGauntletRaceEntry,
getPantheonVersionFirstEntries,
getPlayer,
getPlayerActivityStats,
getPlayerGlobalStats,
Expand Down Expand Up @@ -52,7 +54,9 @@
const statsPromises = Promise.all([
getPlayerActivityStats(membershipId),
getPlayerGlobalStats(membershipId),
getWorldFirstEntries(membershipId)
getWorldFirstEntries(membershipId),
getGauntletRaceEntry(membershipId),
getPantheonVersionFirstEntries(membershipId)
])

const player = await getPlayer(membershipId)
Expand All @@ -70,10 +74,16 @@
return RaidHubRoute.fail(ErrorCode.PlayerPrivateProfileError, { membershipId })
}

const [activityStats, globalStats, worldFirstEntries] = await statsPromises
const [
activityStats,
globalStats,
worldFirstEntries,
gauntletRaceEntry,
pantheonVersionFirstEntries
] = await statsPromises

if (!globalStats) {
throw new Error(`Unexpected error: global stats for player ${membershipId} not found`)

Check warning on line 86 in src/routes/player/membershipId/profile.ts

View workflow job for this annotation

GitHub Actions / test

86 line is not covered with tests
}

return RaidHubRoute.ok({
Expand All @@ -84,12 +94,16 @@
},
worldFirstEntries: Object.fromEntries(
worldFirstEntries.map(
entry =>

Check warning on line 97 in src/routes/player/membershipId/profile.ts

View workflow job for this annotation

GitHub Actions / test

97 line is not covered with tests
[entry.activityId, entry.rank === null ? null : entry] as [
number,
WorldFirstEntry | null
]
)
),
gauntletRaceEntry,
pantheonVersionFirstEntries: Object.fromEntries(
pantheonVersionFirstEntries.map(entry => [entry.versionId, entry])
)
})
}
Expand Down
6 changes: 5 additions & 1 deletion src/schema/components/Instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export const zInstance = registry.register(
activityId: zNaturalNumber(),
versionId: zNaturalNumber(),
isDayOne: z.boolean().openapi({
description: "If the instance was completed before the day one end date"
description:
"If the instance was completed within 24 hours of release. For pantheon modes, uses the version release date (release_date_override when set); otherwise uses the activity day_one_end."
}),
isContest: z.boolean().openapi({
description:
Expand All @@ -47,6 +48,9 @@ export const zInstance = registry.register(
isWeekOne: z.boolean().openapi({
description: "If the instance was completed before the week one end date"
}),
isPantheon: z.boolean().openapi({
description: "If the instance is a pantheon activity mode"
}),
isBlacklisted: z.boolean().openapi({
description: "If the instance is blacklisted from leaderboards"
})
Expand Down
1 change: 1 addition & 0 deletions src/schema/components/InstanceExtended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type InstanceExtended = z.input<typeof zInstanceExtended>
export const zInstanceExtended = zInstance
.extend({
leaderboardRank: zNaturalNumber().nullable(),
isGauntletRace: z.boolean(),
metadata: zInstanceMetadata,
players: z.array(zInstancePlayerExtended)
})
Expand Down
31 changes: 29 additions & 2 deletions src/schema/components/PlayerProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,29 @@ export const zWorldFirstEntry = registry.register(
isDayOne: z.boolean(),
isContest: z.boolean(),
isWeekOne: z.boolean(),
isChallengeMode: z.boolean()
isChallengeMode: z.boolean(),
isGauntletRace: z.boolean().optional()
})
)

export type GauntletRaceEntry = z.input<typeof zGauntletRaceEntry>
export const zGauntletRaceEntry = registry.register(
"GauntletRaceEntry",
z.object({
instanceId: zInt64(),
rank: zNaturalNumber(),
versionId: zNaturalNumber()
})
)

export type PantheonVersionFirstEntry = z.input<typeof zPantheonVersionFirstEntry>
export const zPantheonVersionFirstEntry = registry.register(
"PantheonVersionFirstEntry",
z.object({
versionId: zNaturalNumber(),
instanceId: zInt64(),
rank: zNaturalNumber(),
isDayOne: z.boolean()
})
)

Expand All @@ -61,5 +83,10 @@ export const zPlayerProfile = z.object({
global: zPlayerProfileGlobalStats,
activity: z.record(zNumericalRecordKey(), zPlayerProfileActivityStats)
}),
worldFirstEntries: z.record(zNumericalRecordKey(), zWorldFirstEntry.nullable())
worldFirstEntries: z.record(zNumericalRecordKey(), zWorldFirstEntry.nullable()),
gauntletRaceEntry: zGauntletRaceEntry.nullable(),
pantheonVersionFirstEntries: z.record(
zNumericalRecordKey(),
zPantheonVersionFirstEntry.nullable()
)
})
3 changes: 2 additions & 1 deletion src/services/instance/instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ describe("getLeaderboardEntryForInstance", () => {

const parsed = z
.object({
rank: z.number().int()
rank: z.number().int(),
isGauntletRace: z.boolean()
})
.nullable()
.safeParse(data)
Expand Down
22 changes: 14 additions & 8 deletions src/services/instance/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { InstancePlayerExtended } from "@/schema/components/InstancePlayerExtended"
import { PlayerInfo } from "@/schema/components/PlayerInfo"
import { attachDifficultyTier, resolveDifficultyTier } from "@/services/difficulty-tier/resolve"
import { SQL_IS_DAY_ONE, SQL_IS_PANTHEON } from "@/services/instance/is-day-one"

type InstanceRow = Omit<Instance, "difficultyTier">

Expand All @@ -31,7 +32,7 @@
instance.season_id::int AS "season",
instance.duration::int AS "duration",
instance.platform_type AS "platformType",
instance.date_completed < COALESCE(activity_definition.day_one_end, TIMESTAMP 'epoch') AS "isDayOne",
${SQL_IS_DAY_ONE} AS "isDayOne",
(
CASE
WHEN ig_cact.activity_id IS NOT NULL THEN (
Expand All @@ -42,7 +43,8 @@
END
) AS "isContest",
instance.date_completed < COALESCE(activity_definition.week_one_end, TIMESTAMP 'epoch') AS "isWeekOne",
(b.instance_id IS NOT NULL AND NOT COALESCE(instance.is_whitelisted, false)) AS "isBlacklisted"
(b.instance_id IS NOT NULL AND NOT COALESCE(instance.is_whitelisted, false)) AS "isBlacklisted",
${SQL_IS_PANTHEON} AS "isPantheon"
FROM instance
INNER JOIN activity_version av USING (hash)
INNER JOIN activity_definition ON activity_definition.id = av.activity_id
Expand Down Expand Up @@ -162,9 +164,12 @@
}
const instanceMetadataPromise = getInstanceMetadataByHash(instance.hash)

const leaderboardEntry = await leaderboardEntryPromise

return {
...instance,
leaderboardRank: await leaderboardEntryPromise.then(entry => entry?.rank || null),
leaderboardRank: leaderboardEntry?.rank ?? null,
isGauntletRace: leaderboardEntry?.isGauntletRace ?? false,
metadata: await instanceMetadataPromise,
players: await instancePlayersPromise
}
Expand All @@ -187,7 +192,7 @@
{ params: [hash.toString()] }
)
if (!metaData) {
throw new Error("Metadata not found")

Check warning on line 195 in src/services/instance/instance.ts

View workflow job for this annotation

GitHub Actions / test

195 line is not covered with tests
}
return metaData
}
Expand Down Expand Up @@ -216,27 +221,28 @@
LIMIT 1;`,
{ params: [instanceId] }
)
} catch (error) {
if (
!(
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "42P01"

Check warning on line 230 in src/services/instance/instance.ts

View workflow job for this annotation

GitHub Actions / test

224-230 lines are not covered with tests
)
) {
throw error

Check warning on line 233 in src/services/instance/instance.ts

View workflow job for this annotation

GitHub Actions / test

232-233 lines are not covered with tests
}
}

if (!versionEntry) {
return customRaceEntry
if (customRaceEntry) {
return { rank: customRaceEntry.rank, isGauntletRace: true }

Check warning on line 238 in src/services/instance/instance.ts

View workflow job for this annotation

GitHub Actions / test

238 line is not covered with tests
}
if (!customRaceEntry) {
return versionEntry

if (versionEntry) {
return { rank: versionEntry.rank, isGauntletRace: false }

Check warning on line 242 in src/services/instance/instance.ts

View workflow job for this annotation

GitHub Actions / test

242 line is not covered with tests
}

return versionEntry.rank <= customRaceEntry.rank ? versionEntry : customRaceEntry
return null
}

type InstanceBasicRow = Omit<InstanceBasic, "difficultyTier"> & { activityId: number }
Expand Down
13 changes: 13 additions & 0 deletions src/services/instance/is-day-one.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { sqlIsDayOne } from "@/services/instance/is-day-one"
import { describe, expect, test } from "bun:test"

describe("sqlIsDayOne", () => {
test("uses version release date for pantheon activities", () => {
expect(sqlIsDayOne("instance")).toContain("activity_definition.path = 'pantheon'")
expect(sqlIsDayOne("instance")).toContain("av.release_date_override")
})

test("uses activity day_one_end for non-pantheon activities", () => {
expect(sqlIsDayOne("fastest")).toContain("activity_definition.day_one_end")
})
})
14 changes: 14 additions & 0 deletions src/services/instance/is-day-one.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** SQL expression for isDayOne; requires `activity_definition` and `av` (activity_version) aliases. */
export const sqlIsDayOne = (instanceAlias = "instance") => `(CASE
WHEN activity_definition.path = 'pantheon' THEN
${instanceAlias}.date_completed < (
COALESCE(av.release_date_override, activity_definition.release_date) + INTERVAL '1 day'
)
ELSE
${instanceAlias}.date_completed < COALESCE(activity_definition.day_one_end, TIMESTAMP 'epoch')
END)`

export const SQL_IS_PANTHEON = `(activity_definition.path = 'pantheon')`

/** @deprecated Use sqlIsDayOne() */
export const SQL_IS_DAY_ONE = sqlIsDayOne()
4 changes: 3 additions & 1 deletion src/services/player-instances/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { activityHistoryQueryTimer } from "@/integrations/prometheus/metrics"
import { withHistogramTimer } from "@/integrations/prometheus/util"
import { InstanceForPlayer } from "@/schema/components/InstanceForPlayer"
import { attachDifficultyTiers } from "@/services/difficulty-tier/resolve"
import { SQL_IS_DAY_ONE, SQL_IS_PANTHEON } from "@/services/instance/is-day-one"

export const getActivities = async (
membershipId: bigint | string,
Expand Down Expand Up @@ -53,7 +54,7 @@ export const getActivities = async (
instance.season_id::int AS "season",
instance.duration::int AS "duration",
instance.platform_type AS "platformType",
instance.date_completed < COALESCE(activity_definition.day_one_end, TIMESTAMP 'epoch') AS "isDayOne",
${SQL_IS_DAY_ONE} AS "isDayOne",
(
CASE
WHEN ph_cact.activity_id IS NOT NULL THEN (
Expand All @@ -65,6 +66,7 @@ export const getActivities = async (
) AS "isContest",
instance.date_completed < COALESCE(activity_definition.week_one_end, TIMESTAMP 'epoch') AS "isWeekOne",
(bi.instance_id IS NOT NULL AND NOT COALESCE(instance.is_whitelisted, false)) AS "isBlacklisted",
${SQL_IS_PANTHEON} AS "isPantheon",
JSONB_BUILD_OBJECT(
'completed', instance_player.completed,
'sherpas', instance_player.sherpas::int,
Expand Down
Loading
Loading