diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 25b9e72f7..fba3a5178 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -158,6 +158,18 @@ export function GroupCard({ {group.tier.isPlus ? "+" : ""} ) : null} + {group.tierRange ? ( +
+
+ ±{group.tierRange.diff} +
+
+ + — + +
+
+ ) : null} {group.skillDifference ? ( ) : null} diff --git a/app/features/sendouq/core/groups.server.test.ts b/app/features/sendouq/core/groups.server.test.ts new file mode 100644 index 000000000..836b4a4cf --- /dev/null +++ b/app/features/sendouq/core/groups.server.test.ts @@ -0,0 +1,92 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import { tierDifferenceToRangeOrExact } from "./groups.server"; + +const paramsToExpected = new Map< + [ + Parameters[0]["ourTier"], + Parameters[0]["theirTier"], + ], + ReturnType["tier"] +>() + // exact + .set( + [ + { isPlus: false, name: "GOLD" }, + { isPlus: false, name: "GOLD" }, + ], + { isPlus: false, name: "GOLD" }, + ) + // 1 place difference + .set( + [ + { isPlus: false, name: "GOLD" }, + { isPlus: true, name: "GOLD" }, + ], + [ + { isPlus: true, name: "SILVER" }, + { isPlus: true, name: "GOLD" }, + ], + ) + // 2 places difference + .set( + [ + { isPlus: false, name: "GOLD" }, + { isPlus: false, name: "PLATINUM" }, + ], + [ + { isPlus: false, name: "SILVER" }, + { isPlus: false, name: "PLATINUM" }, + ], + ) + // too high, has to be exact + .set( + [ + { isPlus: true, name: "LEVIATHAN" }, + { isPlus: false, name: "LEVIATHAN" }, + ], + { isPlus: false, name: "LEVIATHAN" }, + ) + // too low, has to be exact + .set( + [ + { isPlus: false, name: "IRON" }, + { isPlus: true, name: "IRON" }, + ], + { isPlus: true, name: "IRON" }, + ) + // not max rank but still too high + .set( + [ + { isPlus: false, name: "LEVIATHAN" }, + { isPlus: false, name: "DIAMOND" }, + ], + { isPlus: false, name: "DIAMOND" }, + ); + +const TierDifferenceToRangeOrExact = suite("tierDifferenceToRangeOrExact()"); + +for (const [input, expected] of paramsToExpected) { + TierDifferenceToRangeOrExact( + `works for ${JSON.stringify(input)} -> ${JSON.stringify(expected)}`, + () => { + const result = tierDifferenceToRangeOrExact({ + ourTier: input[0], + theirTier: input[1], + hasLeviathan: true, + }).tier; + assert.equal(result, expected); + }, + ); +} + +TierDifferenceToRangeOrExact("works before leviathan", () => { + const result = tierDifferenceToRangeOrExact({ + ourTier: { isPlus: true, name: "DIAMOND" }, + theirTier: { isPlus: false, name: "DIAMOND" }, + hasLeviathan: false, + }).tier; + assert.equal(result, { isPlus: false, name: "DIAMOND" }); +}); + +TierDifferenceToRangeOrExact.run(); diff --git a/app/features/sendouq/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts index bbe76a0c1..16903b433 100644 --- a/app/features/sendouq/core/groups.server.ts +++ b/app/features/sendouq/core/groups.server.ts @@ -1,3 +1,4 @@ +import clone from "just-clone"; import type { Tables } from "~/db/tables"; import type { Group } from "~/db/types"; import { TIERS } from "~/features/mmr/mmr-constants"; @@ -304,21 +305,6 @@ export function sortGroupsBySkillAndSentiment({ return { ...groups, neutral: groups.neutral.sort((a, b) => { - const aTier = - a.tier?.name ?? - resolveGroupSkill({ - group: a as LookingGroupWithInviteCode, - userSkills, - intervals, - })?.name; - const bTier = - b.tier?.name ?? - resolveGroupSkill({ - group: b as LookingGroupWithInviteCode, - userSkills, - intervals, - })?.name; - const aSentiment = groupSentiment(a); const bSentiment = groupSentiment(b); @@ -329,6 +315,16 @@ export function sortGroupsBySkillAndSentiment({ if (bSentiment === "POSITIVE") return 1; } + const aDiff = a.tierRange?.diff ?? 0; + const bDiff = b.tierRange?.diff ?? 0; + + if (aDiff || bDiff) { + return aDiff - bDiff; + } + + const aTier = a.tier?.name; + const bTier = b.tier?.name; + const aTierDiff = tierDiff(aTier); const bTierDiff = tierDiff(bTier); @@ -375,6 +371,36 @@ export function addSkillsToGroups({ }; } +const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const; +export function addSkillRangeToGroups({ + groups, + hasLeviathan, +}: { groups: DividedGroups; hasLeviathan: boolean }) { + const addRange = (group: LookingGroup) => { + if (group.members && group.members.length !== FULL_GROUP_SIZE) return group; + + const range = tierDifferenceToRangeOrExact({ + ourTier: groups.own?.tier ?? FALLBACK_TIER, + theirTier: group.tier ?? FALLBACK_TIER, + hasLeviathan, + }); + + if (!Array.isArray(range.tier)) return group; + + return { + ...group, + tierRange: { range: range.tier, diff: range.diff }, + tier: undefined, + }; + }; + + return { + own: groups.own, + neutral: groups.neutral.map(addRange), + likesReceived: groups.likesReceived.map(addRange), + }; +} + export function membersNeededForFull(currentSize: number) { return FULL_GROUP_SIZE - currentSize; } @@ -395,14 +421,11 @@ function resolveGroupSkill({ const averageOrdinal = skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length; - const tier = intervals.find( - (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal, - ) ?? { isPlus: false, name: "IRON" }; - - // For Leviathan we don't specify if it's plus or not - return tier.name === "LEVIATHAN" - ? { name: "LEVIATHAN", isPlus: false } - : { name: tier.name, isPlus: tier.isPlus }; + return ( + intervals.find( + (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal, + ) ?? { isPlus: false, name: "IRON" } + ); } export function groupExpiryStatus( @@ -428,3 +451,51 @@ export function groupExpiryStatus( return null; } + +const allTiersOrdered = TIERS.flatMap((tier) => [ + { name: tier.name, isPlus: true }, + { name: tier.name, isPlus: false }, +]).reverse(); +export function tierDifferenceToRangeOrExact({ + ourTier, + theirTier, + hasLeviathan, +}: { + ourTier: TieredSkill["tier"]; + theirTier: TieredSkill["tier"]; + hasLeviathan: boolean; +}): { + diff: number; + tier: TieredSkill["tier"] | [TieredSkill["tier"], TieredSkill["tier"]]; +} { + if (ourTier.name === theirTier.name && ourTier.isPlus === theirTier.isPlus) { + return { diff: 0, tier: clone(ourTier) }; + } + + const tiers = hasLeviathan + ? allTiersOrdered + : allTiersOrdered.filter((tier) => tier.name !== "LEVIATHAN"); + + const tier1Idx = tiers.findIndex( + (t) => t.name === ourTier.name && t.isPlus === ourTier.isPlus, + ); + const tier2Idx = tiers.findIndex( + (t) => t.name === theirTier.name && t.isPlus === theirTier.isPlus, + ); + invariant(tier1Idx !== -1, "tier1 not found"); + invariant(tier2Idx !== -1, "tier2 not found"); + + const idxDiff = Math.abs(tier1Idx - tier2Idx); + + const lowerBound = tier1Idx - idxDiff; + const upperBound = tier1Idx + idxDiff; + + if (lowerBound < 0 || upperBound >= tiers.length) { + return { diff: idxDiff, tier: clone(theirTier) }; + } + + const lowerTier = tiers[lowerBound]; + const upperTier = tiers[upperBound]; + + return { diff: idxDiff, tier: [clone(lowerTier), clone(upperTier)] }; +} diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts index 4f5d148cf..56d8fea29 100644 --- a/app/features/sendouq/q-types.ts +++ b/app/features/sendouq/q-types.ts @@ -14,6 +14,10 @@ export type LookingGroup = { id: number; createdAt: Group["createdAt"]; tier?: TieredSkill["tier"]; + tierRange?: { + range: [TieredSkill["tier"], TieredSkill["tier"]]; + diff: number; + }; isReplay?: boolean; isNoScreen?: boolean; isLiked?: boolean; diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css index af7c8864d..4418d7685 100644 --- a/app/features/sendouq/q.css +++ b/app/features/sendouq/q.css @@ -318,6 +318,10 @@ transform: translate(-50%, -50%); } +.q__group__tier-diff-text { + font-size: 3rem; +} + .q__member-adder__input { --input-width: 11rem; width: 11rem; diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 71a3d87e2..bdc694a3e 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -52,6 +52,7 @@ import { addFutureMatchModes, addNoScreenIndicator, addReplayIndicator, + addSkillRangeToGroups, addSkillsToGroups, censorGroups, divideGroups, @@ -455,9 +456,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const season = currentOrPreviousSeason(new Date()); - const { intervals, userSkills: calculatedUserSkills } = userSkills( - season!.nth, - ); + const { + intervals, + userSkills: calculatedUserSkills, + isAccurateTiers, + } = userSkills(season!.nth); const groupsWithSkills = addSkillsToGroups({ groups: dividedGroups, intervals, @@ -485,8 +488,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { : false, }); - const sortedGroups = sortGroupsBySkillAndSentiment({ + const rangedGroups = addSkillRangeToGroups({ groups: censoredGroups, + hasLeviathan: isAccurateTiers, + }); + + const sortedGroups = sortGroupsBySkillAndSentiment({ + groups: rangedGroups, intervals, userSkills: calculatedUserSkills, userId: user?.id,