diff --git a/open-api/openapi.json b/open-api/openapi.json index 21e93ce..1d18f2a 100644 --- a/open-api/openapi.json +++ b/open-api/openapi.json @@ -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", @@ -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" @@ -351,6 +355,7 @@ "isDayOne", "isContest", "isWeekOne", + "isPantheon", "isBlacklisted" ], "additionalProperties": false @@ -1727,6 +1732,9 @@ }, "isChallengeMode": { "type": "boolean" + }, + "isGauntletRace": { + "type": "boolean" } }, "required": [ @@ -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": { @@ -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", @@ -2606,6 +2674,9 @@ "minimum": 0, "exclusiveMinimum": true }, + "isGauntletRace": { + "type": "boolean" + }, "metadata": { "$ref": "#/components/schemas/InstanceMetadata" }, @@ -2616,7 +2687,12 @@ } } }, - "required": ["leaderboardRank", "metadata", "players"], + "required": [ + "leaderboardRank", + "isGauntletRace", + "metadata", + "players" + ], "additionalProperties": false } ] diff --git a/src/routes/player/membershipId/profile.ts b/src/routes/player/membershipId/profile.ts index 5d587c6..d8d4c90 100644 --- a/src/routes/player/membershipId/profile.ts +++ b/src/routes/player/membershipId/profile.ts @@ -7,6 +7,8 @@ import { ErrorCode } from "@/schema/errors/ErrorCode" import { zBigIntString } from "@/schema/input" import { zInt64 } from "@/schema/output" import { + getGauntletRaceEntry, + getPantheonVersionFirstEntries, getPlayer, getPlayerActivityStats, getPlayerGlobalStats, @@ -52,7 +54,9 @@ This is used to hydrate the RaidHub profile page`, const statsPromises = Promise.all([ getPlayerActivityStats(membershipId), getPlayerGlobalStats(membershipId), - getWorldFirstEntries(membershipId) + getWorldFirstEntries(membershipId), + getGauntletRaceEntry(membershipId), + getPantheonVersionFirstEntries(membershipId) ]) const player = await getPlayer(membershipId) @@ -70,7 +74,13 @@ This is used to hydrate the RaidHub profile page`, 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`) @@ -90,6 +100,10 @@ This is used to hydrate the RaidHub profile page`, WorldFirstEntry | null ] ) + ), + gauntletRaceEntry, + pantheonVersionFirstEntries: Object.fromEntries( + pantheonVersionFirstEntries.map(entry => [entry.versionId, entry]) ) }) } diff --git a/src/schema/components/Instance.ts b/src/schema/components/Instance.ts index c0faa02..e873662 100644 --- a/src/schema/components/Instance.ts +++ b/src/schema/components/Instance.ts @@ -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: @@ -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" }) diff --git a/src/schema/components/InstanceExtended.ts b/src/schema/components/InstanceExtended.ts index a3a96e4..17eda9d 100644 --- a/src/schema/components/InstanceExtended.ts +++ b/src/schema/components/InstanceExtended.ts @@ -9,6 +9,7 @@ export type InstanceExtended = z.input export const zInstanceExtended = zInstance .extend({ leaderboardRank: zNaturalNumber().nullable(), + isGauntletRace: z.boolean(), metadata: zInstanceMetadata, players: z.array(zInstancePlayerExtended) }) diff --git a/src/schema/components/PlayerProfile.ts b/src/schema/components/PlayerProfile.ts index 8dfdfbc..9064f81 100644 --- a/src/schema/components/PlayerProfile.ts +++ b/src/schema/components/PlayerProfile.ts @@ -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 +export const zGauntletRaceEntry = registry.register( + "GauntletRaceEntry", + z.object({ + instanceId: zInt64(), + rank: zNaturalNumber(), + versionId: zNaturalNumber() + }) +) + +export type PantheonVersionFirstEntry = z.input +export const zPantheonVersionFirstEntry = registry.register( + "PantheonVersionFirstEntry", + z.object({ + versionId: zNaturalNumber(), + instanceId: zInt64(), + rank: zNaturalNumber(), + isDayOne: z.boolean() }) ) @@ -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() + ) }) diff --git a/src/services/instance/instance.test.ts b/src/services/instance/instance.test.ts index 0374fba..b8a1cc8 100644 --- a/src/services/instance/instance.test.ts +++ b/src/services/instance/instance.test.ts @@ -197,7 +197,8 @@ describe("getLeaderboardEntryForInstance", () => { const parsed = z .object({ - rank: z.number().int() + rank: z.number().int(), + isGauntletRace: z.boolean() }) .nullable() .safeParse(data) diff --git a/src/services/instance/instance.ts b/src/services/instance/instance.ts index 8675c05..93e2140 100644 --- a/src/services/instance/instance.ts +++ b/src/services/instance/instance.ts @@ -10,6 +10,7 @@ import { InstanceMetadata } from "@/schema/components/InstanceMetadata" 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 @@ -31,7 +32,7 @@ export async function getInstance(instanceId: bigint | string): Promise entry?.rank || null), + leaderboardRank: leaderboardEntry?.rank ?? null, + isGauntletRace: leaderboardEntry?.isGauntletRace ?? false, metadata: await instanceMetadataPromise, players: await instancePlayersPromise } @@ -229,14 +234,15 @@ export const getLeaderboardEntryForInstance = async (instanceId: bigint | string } } - if (!versionEntry) { - return customRaceEntry + if (customRaceEntry) { + return { rank: customRaceEntry.rank, isGauntletRace: true } } - if (!customRaceEntry) { - return versionEntry + + if (versionEntry) { + return { rank: versionEntry.rank, isGauntletRace: false } } - return versionEntry.rank <= customRaceEntry.rank ? versionEntry : customRaceEntry + return null } type InstanceBasicRow = Omit & { activityId: number } diff --git a/src/services/instance/is-day-one.test.ts b/src/services/instance/is-day-one.test.ts new file mode 100644 index 0000000..d2f5a5e --- /dev/null +++ b/src/services/instance/is-day-one.test.ts @@ -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") + }) +}) diff --git a/src/services/instance/is-day-one.ts b/src/services/instance/is-day-one.ts new file mode 100644 index 0000000..365f889 --- /dev/null +++ b/src/services/instance/is-day-one.ts @@ -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() diff --git a/src/services/player-instances/history.ts b/src/services/player-instances/history.ts index 5be304a..20fff1a 100644 --- a/src/services/player-instances/history.ts +++ b/src/services/player-instances/history.ts @@ -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, @@ -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 ( @@ -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, diff --git a/src/services/player-instances/instances.ts b/src/services/player-instances/instances.ts index df52431..05efc0d 100644 --- a/src/services/player-instances/instances.ts +++ b/src/services/player-instances/instances.ts @@ -6,6 +6,7 @@ import { } from "@/integrations/postgres/transformer" import { InstanceWithPlayers } from "@/schema/components/InstanceWithPlayers" import { attachDifficultyTiers } from "@/services/difficulty-tier/resolve" +import { SQL_IS_DAY_ONE, SQL_IS_PANTHEON } from "@/services/instance/is-day-one" export async function getInstances({ count, @@ -140,7 +141,7 @@ export async function getInstances({ 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 pi2_cact.activity_id IS NOT NULL THEN ( @@ -152,6 +153,7 @@ export async function getInstances({ ) 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", + ${SQL_IS_PANTHEON} AS "isPantheon", "_lateral".players AS "players" FROM _player_instances INNER JOIN instance USING (instance_id) diff --git a/src/services/player.ts b/src/services/player.ts index 7a7cf52..ee7cb67 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -4,10 +4,13 @@ import { playerProfileQueryTimer } from "@/integrations/prometheus/metrics" import { withHistogramTimer } from "@/integrations/prometheus/util" import { PlayerInfo } from "@/schema/components/PlayerInfo" import { + GauntletRaceEntry, + PantheonVersionFirstEntry, PlayerProfileActivityStats, PlayerProfileGlobalStats, WorldFirstEntry } from "@/schema/components/PlayerProfile" +import { sqlIsDayOne } from "@/services/instance/is-day-one" export const getPlayer = async (membershipId: bigint | string) => { return await pgReader.queryRow( @@ -56,7 +59,7 @@ export const getPlayerActivityStats = async (membershipId: bigint | string) => { 'season', fastest.season_id::int, 'duration', fastest.duration::int, 'platformType', fastest.platform_type, - 'isDayOne', fastest.date_completed < COALESCE(activity_definition.day_one_end, TIMESTAMP 'epoch'), + 'isDayOne', ${sqlIsDayOne("fastest")}, 'isContest', ( CASE WHEN fa_cact.activity_id IS NOT NULL THEN ( @@ -188,3 +191,62 @@ export const getWorldFirstEntries = async (membershipId: bigint | string) => { ) ) } + +export const getGauntletRaceEntry = async (membershipId: bigint | string) => { + return await withHistogramTimer( + playerProfileQueryTimer, + { + method: "getGauntletRaceEntry" + }, + async () => { + try { + return await pgReader.queryRow( + `SELECT + pcr.instance_id AS "instanceId", + pcr.rank::int AS "rank", + 134::int AS "versionId" + FROM team_pantheon_custom_race_leaderboard pcr + WHERE pcr.membership_ids @> $1::jsonb + ORDER BY pcr.rank ASC + LIMIT 1;`, + { params: [`${[membershipId]}`] } + ) + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "42P01" + ) { + return null + } + throw error + } + } + ) +} + +export const getPantheonVersionFirstEntries = async (membershipId: bigint | string) => { + return await withHistogramTimer( + playerProfileQueryTimer, + { + method: "getPantheonVersionFirstEntries" + }, + () => + pgReader.queryRows( + `SELECT DISTINCT ON (tavl.version_id) + tavl.version_id::int AS "versionId", + tavl.instance_id AS "instanceId", + tavl.rank::int AS "rank", + ${sqlIsDayOne("i")} AS "isDayOne" + FROM team_activity_version_leaderboard tavl + INNER JOIN instance i USING (instance_id) + INNER JOIN activity_version av ON av.hash = i.hash + INNER JOIN activity_definition ON activity_definition.id = av.activity_id + WHERE tavl.membership_ids @> $1::jsonb + AND activity_definition.path = 'pantheon' + ORDER BY tavl.version_id ASC, tavl.rank ASC;`, + { params: [`${[membershipId]}`] } + ) + ) +}