Add censorship and bias pairing towards dense Elo buckets
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { assets, lensPortraits } from "@/db/schema";
|
||||
@@ -11,10 +11,12 @@ const UUID_RE =
|
||||
const ALLOWED_SIZES = new Set(["preview", "thumbnail", "fullsize"]);
|
||||
|
||||
async function isAuthorizedAsset(id: string): Promise<boolean> {
|
||||
// Only serve thumbnails for active assets; lens portraits live outside the
|
||||
// album so they're authorized independently of asset state.
|
||||
const [asset] = await db
|
||||
.select({ id: assets.id })
|
||||
.from(assets)
|
||||
.where(eq(assets.id, id))
|
||||
.where(and(eq(assets.id, id), eq(assets.active, true)))
|
||||
.limit(1);
|
||||
if (asset) return true;
|
||||
|
||||
|
||||
@@ -12,24 +12,37 @@ export function HeadToHead({ data }: { data: HeadToHeadEntry[] }) {
|
||||
Head to Head
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{data.map((h2h) => (
|
||||
<Link
|
||||
key={h2h.opponentId}
|
||||
href={`/assets/${h2h.opponentId}`}
|
||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
|
||||
>
|
||||
<img
|
||||
src={`/img/${h2h.opponentId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-16 rounded object-contain"
|
||||
style={{ aspectRatio: h2h.opponentAspectRatio }}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
|
||||
<span className="text-success">{h2h.wins}W</span>
|
||||
<span className="text-danger">{h2h.losses}L</span>
|
||||
{data.map((h2h) =>
|
||||
h2h.inactive ? (
|
||||
<div
|
||||
key={h2h.opponentId}
|
||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg"
|
||||
>
|
||||
<div className="h-16 aspect-square rounded bg-default" />
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
|
||||
<span className="text-success">{h2h.wins}W</span>
|
||||
<span className="text-danger">{h2h.losses}L</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
) : (
|
||||
<Link
|
||||
key={h2h.opponentId}
|
||||
href={`/assets/${h2h.opponentId}`}
|
||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
|
||||
>
|
||||
<img
|
||||
src={`/img/${h2h.opponentId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-16 rounded object-contain"
|
||||
style={{ aspectRatio: h2h.opponentAspectRatio ?? 1 }}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
|
||||
<span className="text-success">{h2h.wins}W</span>
|
||||
<span className="text-danger">{h2h.losses}L</span>
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -53,17 +53,23 @@ export function AssetMatchHistory({
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="flex justify-center">
|
||||
<Link
|
||||
href={`/assets/${match.opponentId}`}
|
||||
className="inline-flex justify-center"
|
||||
>
|
||||
<img
|
||||
src={`/img/${match.opponentId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-8 rounded object-contain"
|
||||
style={{ aspectRatio: match.opponentAspectRatio }}
|
||||
/>
|
||||
</Link>
|
||||
{match.opponentId ? (
|
||||
<Link
|
||||
href={`/assets/${match.opponentId}`}
|
||||
className="inline-flex justify-center"
|
||||
>
|
||||
<img
|
||||
src={`/img/${match.opponentId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-8 rounded object-contain"
|
||||
style={{
|
||||
aspectRatio: match.opponentAspectRatio ?? 1,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="h-8 aspect-square rounded bg-default" />
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums text-xs">
|
||||
{formatElo(match.assetEloBefore)} →{" "}
|
||||
|
||||
@@ -16,17 +16,21 @@ function MatchRows({ matches }: { matches: MatchEntry[] }) {
|
||||
{matches.map((match) => (
|
||||
<Table.Row key={match.id}>
|
||||
<Table.Cell className="text-center">
|
||||
<Link
|
||||
href={`/assets/${match.leftId}`}
|
||||
className="inline-flex justify-center"
|
||||
>
|
||||
<img
|
||||
src={`/img/${match.leftId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-8 rounded object-contain"
|
||||
style={{ aspectRatio: match.leftAspectRatio }}
|
||||
/>
|
||||
</Link>
|
||||
{match.leftId ? (
|
||||
<Link
|
||||
href={`/assets/${match.leftId}`}
|
||||
className="inline-flex justify-center"
|
||||
>
|
||||
<img
|
||||
src={`/img/${match.leftId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-8 rounded object-contain"
|
||||
style={{ aspectRatio: match.leftAspectRatio ?? 1 }}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-block h-8 aspect-square rounded bg-default" />
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
className={`text-center text-xs tabular-nums font-semibold ${match.winningSide === "left" ? "text-success" : "text-danger"}`}
|
||||
@@ -42,17 +46,21 @@ function MatchRows({ matches }: { matches: MatchEntry[] }) {
|
||||
{formatElo(match.rightEloAfter)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-center">
|
||||
<Link
|
||||
href={`/assets/${match.rightId}`}
|
||||
className="inline-flex justify-center"
|
||||
>
|
||||
<img
|
||||
src={`/img/${match.rightId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-8 rounded object-contain"
|
||||
style={{ aspectRatio: match.rightAspectRatio }}
|
||||
/>
|
||||
</Link>
|
||||
{match.rightId ? (
|
||||
<Link
|
||||
href={`/assets/${match.rightId}`}
|
||||
className="inline-flex justify-center"
|
||||
>
|
||||
<img
|
||||
src={`/img/${match.rightId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-8 rounded object-contain"
|
||||
style={{ aspectRatio: match.rightAspectRatio ?? 1 }}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-block h-8 aspect-square rounded bg-default" />
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<TimeAgo date={match.createdAt} />
|
||||
|
||||
@@ -19,22 +19,30 @@ export function UpsetsList({ data }: { data: UpsetEntry[] }) {
|
||||
className="relative flex items-center justify-between p-3 rounded-lg bg-surface"
|
||||
>
|
||||
{/* Thumbnails at the edges */}
|
||||
<Link href={`/assets/${upset.winnerId}`} className="inline-flex">
|
||||
<img
|
||||
src={`/img/${upset.winnerId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-18 rounded object-contain"
|
||||
style={{ aspectRatio: upset.winnerAspectRatio }}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={`/assets/${upset.loserId}`} className="inline-flex">
|
||||
<img
|
||||
src={`/img/${upset.loserId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-18 rounded object-contain"
|
||||
style={{ aspectRatio: upset.loserAspectRatio }}
|
||||
/>
|
||||
</Link>
|
||||
{upset.winnerId ? (
|
||||
<Link href={`/assets/${upset.winnerId}`} className="inline-flex">
|
||||
<img
|
||||
src={`/img/${upset.winnerId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-18 rounded object-contain"
|
||||
style={{ aspectRatio: upset.winnerAspectRatio ?? 1 }}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="h-18 aspect-square rounded bg-default" />
|
||||
)}
|
||||
{upset.loserId ? (
|
||||
<Link href={`/assets/${upset.loserId}`} className="inline-flex">
|
||||
<img
|
||||
src={`/img/${upset.loserId}?size=thumbnail`}
|
||||
alt=""
|
||||
className="h-18 rounded object-contain"
|
||||
style={{ aspectRatio: upset.loserAspectRatio ?? 1 }}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="h-18 aspect-square rounded bg-default" />
|
||||
)}
|
||||
|
||||
{/* Centered text overlay — "beat" anchored to exact center */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
|
||||
+50
-20
@@ -1,8 +1,13 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { assets, eloHistory, lenses, matches } from "@/db/schema";
|
||||
import { calculateElo } from "./elo";
|
||||
|
||||
function opaqueToken(id: string): string {
|
||||
return createHash("sha256").update(id).digest("base64url").slice(0, 16);
|
||||
}
|
||||
|
||||
// ---------- Asset by ID ----------
|
||||
|
||||
export async function getAssetById(id: string) {
|
||||
@@ -14,7 +19,7 @@ export async function getAssetById(id: string) {
|
||||
})
|
||||
.from(assets)
|
||||
.leftJoin(lenses, eq(assets.lensId, lenses.id))
|
||||
.where(eq(assets.id, id));
|
||||
.where(and(eq(assets.id, id), eq(assets.active, true)));
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row.asset,
|
||||
@@ -27,8 +32,8 @@ export async function getAssetById(id: string) {
|
||||
|
||||
export interface AssetMatchEntry {
|
||||
id: number;
|
||||
opponentId: string;
|
||||
opponentAspectRatio: number;
|
||||
opponentId: string | null;
|
||||
opponentAspectRatio: number | null;
|
||||
won: boolean;
|
||||
assetEloBefore: number;
|
||||
assetEloAfter: number;
|
||||
@@ -51,12 +56,16 @@ export async function getAssetMatchHistory(
|
||||
.limit(limit)
|
||||
.offset(page * limit);
|
||||
|
||||
// Fetch opponent aspect ratios
|
||||
// Fetch opponent aspect ratios and active flag; inactive opponents become censored placeholders.
|
||||
const opponentIds = rows.map((r) =>
|
||||
r.winnerId === assetId ? r.loserId : r.winnerId,
|
||||
);
|
||||
const opponents = await db
|
||||
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
|
||||
.select({
|
||||
id: assets.id,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
active: assets.active,
|
||||
})
|
||||
.from(assets)
|
||||
.where(
|
||||
sql`${assets.id} IN (${sql.join(
|
||||
@@ -64,7 +73,7 @@ export async function getAssetMatchHistory(
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
|
||||
const oppMap = new Map(opponents.map((o) => [o.id, o]));
|
||||
|
||||
return rows.map((r) => {
|
||||
const won = r.winnerId === assetId;
|
||||
@@ -72,11 +81,14 @@ export async function getAssetMatchHistory(
|
||||
r.winnerEloBefore,
|
||||
r.loserEloBefore,
|
||||
);
|
||||
const opponentRawId = won ? r.loserId : r.winnerId;
|
||||
const opp = oppMap.get(opponentRawId);
|
||||
const visible = opp?.active === true;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
opponentId: won ? r.loserId : r.winnerId,
|
||||
opponentAspectRatio: arMap.get(won ? r.loserId : r.winnerId) ?? 1,
|
||||
opponentId: visible ? opponentRawId : null,
|
||||
opponentAspectRatio: visible ? (opp?.aspectRatio ?? null) : null,
|
||||
won,
|
||||
assetEloBefore: won ? r.winnerEloBefore : r.loserEloBefore,
|
||||
assetEloAfter: won ? newWinnerElo : newLoserElo,
|
||||
@@ -150,7 +162,8 @@ export async function getAssetEloHistory(
|
||||
|
||||
export interface HeadToHeadEntry {
|
||||
opponentId: string;
|
||||
opponentAspectRatio: number;
|
||||
opponentAspectRatio: number | null;
|
||||
inactive?: true;
|
||||
wins: number;
|
||||
losses: number;
|
||||
total: number;
|
||||
@@ -179,12 +192,15 @@ export async function getHeadToHead(
|
||||
tally.set(opponentId, entry);
|
||||
}
|
||||
|
||||
// Fetch opponent aspect ratios
|
||||
const opponentIds = [...tally.keys()];
|
||||
if (opponentIds.length === 0) return [];
|
||||
|
||||
const opponents = await db
|
||||
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
|
||||
.select({
|
||||
id: assets.id,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
active: assets.active,
|
||||
})
|
||||
.from(assets)
|
||||
.where(
|
||||
sql`${assets.id} IN (${sql.join(
|
||||
@@ -192,16 +208,30 @@ export async function getHeadToHead(
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
|
||||
const oppMap = new Map(opponents.map((o) => [o.id, o]));
|
||||
|
||||
return [...tally.entries()]
|
||||
.map(([opponentId, { wins, losses }]) => ({
|
||||
opponentId,
|
||||
opponentAspectRatio: arMap.get(opponentId) ?? 1,
|
||||
wins,
|
||||
losses,
|
||||
total: wins + losses,
|
||||
}))
|
||||
.map(([opponentId, { wins, losses }]) => {
|
||||
const opp = oppMap.get(opponentId);
|
||||
const total = wins + losses;
|
||||
if (opp?.active) {
|
||||
return {
|
||||
opponentId,
|
||||
opponentAspectRatio: opp.aspectRatio,
|
||||
wins,
|
||||
losses,
|
||||
total,
|
||||
};
|
||||
}
|
||||
return {
|
||||
opponentId: opaqueToken(opponentId),
|
||||
opponentAspectRatio: null,
|
||||
inactive: true as const,
|
||||
wins,
|
||||
losses,
|
||||
total,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
@@ -225,7 +255,7 @@ export async function getEloNeighbors(
|
||||
eloProvisional: assets.eloProvisional,
|
||||
})
|
||||
.from(assets)
|
||||
.where(sql`${assets.id} != ${assetId}`)
|
||||
.where(and(sql`${assets.id} != ${assetId}`, eq(assets.active, true)))
|
||||
.orderBy(sql`ABS(${assets.eloProvisional} - ${elo})`)
|
||||
.limit(limit);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { assets, lenses, lensPortraits } from "@/db/schema";
|
||||
import { fetchAsset, type ImmichAsset } from "./immich";
|
||||
@@ -188,7 +188,7 @@ export async function getLensDetail(
|
||||
})
|
||||
.from(lenses)
|
||||
.innerJoin(assets, eq(assets.lensId, lenses.id))
|
||||
.where(eq(lenses.id, lensId))
|
||||
.where(and(eq(lenses.id, lensId), eq(assets.active, true)))
|
||||
.groupBy(lenses.id);
|
||||
|
||||
if (!agg) return null;
|
||||
@@ -230,7 +230,7 @@ export async function getLensAssets(
|
||||
iso: assets.iso,
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.lensId, lensId))
|
||||
.where(and(eq(assets.lensId, lensId), eq(assets.active, true)))
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(page * limit);
|
||||
|
||||
+38
-9
@@ -1,6 +1,10 @@
|
||||
import { and, eq, gt, inArray, isNull } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { assets, lenses, pairings } from "@/db/schema";
|
||||
import { getEloDistribution } from "./stats";
|
||||
|
||||
const DENSITY_BUCKET_SIZE = 25;
|
||||
const DENSITY_EXPONENT = 0.5;
|
||||
|
||||
const PAIRING_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
@@ -93,6 +97,18 @@ function proximityWeight(eloA: number, eloB: number): number {
|
||||
return 1 / (1 + (diff * diff) / 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spike-smearing term: star-seed clusters are dense piles of well-played assets that the
|
||||
* uncertainty bias can't dislodge (they're not under-played). Weighting A by local Elo density
|
||||
* preferentially picks out of those clusters so wins/losses diffuse them outward. Sub-linear
|
||||
* exponent keeps valley assets sampleable.
|
||||
*/
|
||||
function densityWeight(elo: number, counts: Map<number, number>): number {
|
||||
const bucket = Math.floor(elo / DENSITY_BUCKET_SIZE) * DENSITY_BUCKET_SIZE;
|
||||
const count = counts.get(bucket) ?? 1;
|
||||
return count ** DENSITY_EXPONENT;
|
||||
}
|
||||
|
||||
function weightedPick<T>(items: T[], weights: number[]): T {
|
||||
const total = weights.reduce((sum, w) => sum + w, 0);
|
||||
let rand = Math.random() * total;
|
||||
@@ -105,21 +121,26 @@ function weightedPick<T>(items: T[], weights: number[]): T {
|
||||
|
||||
/**
|
||||
* Generate a weighted random pairing. Sequentially samples two assets to
|
||||
* approximate joint info-gain: A is drawn by inverse match count (favour
|
||||
* under-played assets), then B by inverse match count × Elo proximity to A.
|
||||
* approximate joint info-gain: A is drawn by inverse match count × local Elo
|
||||
* density (favour under-played assets and dissolve seed-induced spikes), then
|
||||
* B by inverse match count × Elo proximity to A.
|
||||
*/
|
||||
export async function generatePairing(
|
||||
voterIp: string,
|
||||
): Promise<PairingResult | null> {
|
||||
const activeAssets = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(eq(assets.active, true));
|
||||
const [activeAssets, distribution] = await Promise.all([
|
||||
db.select().from(assets).where(eq(assets.active, true)),
|
||||
getEloDistribution(DENSITY_BUCKET_SIZE),
|
||||
]);
|
||||
|
||||
if (activeAssets.length < 2) return null;
|
||||
|
||||
const aWeights = activeAssets.map((a) =>
|
||||
uncertaintyWeight(a.matchesProvisional),
|
||||
const densityCounts = new Map(distribution.map((d) => [d.bucket, d.count]));
|
||||
|
||||
const aWeights = activeAssets.map(
|
||||
(a) =>
|
||||
uncertaintyWeight(a.matchesProvisional) *
|
||||
densityWeight(a.eloProvisional, densityCounts),
|
||||
);
|
||||
const assetA = weightedPick(activeAssets, aWeights);
|
||||
|
||||
@@ -184,7 +205,15 @@ export async function getActivePairing(
|
||||
.from(assets)
|
||||
.where(eq(assets.id, existing.assetBId));
|
||||
|
||||
if (!assetA || !assetB) return null;
|
||||
// If either side is gone or has been removed from the album since the pairing
|
||||
// was generated, expire it so the caller falls through to a fresh pairing.
|
||||
if (!assetA || !assetB || !assetA.active || !assetB.active) {
|
||||
await db
|
||||
.update(pairings)
|
||||
.set({ expiresAt: now })
|
||||
.where(eq(pairings.uuid, existing.uuid));
|
||||
return null;
|
||||
}
|
||||
|
||||
const lensMap = await fetchLensMap([assetA.lensId, assetB.lensId]);
|
||||
|
||||
|
||||
+37
-16
@@ -1,4 +1,4 @@
|
||||
import { asc, desc, eq, isNotNull, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { assets, lenses, matches, pairings } from "@/db/schema";
|
||||
import { calculateElo } from "./elo";
|
||||
@@ -53,6 +53,7 @@ export async function getLeaderboard(
|
||||
})
|
||||
.from(assets)
|
||||
.leftJoin(lenses, eq(assets.lensId, lenses.id))
|
||||
.where(eq(assets.active, true))
|
||||
.orderBy(orderFn(col))
|
||||
.limit(limit)
|
||||
.offset(page * limit);
|
||||
@@ -80,10 +81,10 @@ export async function getLeaderboard(
|
||||
|
||||
export interface MatchEntry {
|
||||
id: number;
|
||||
leftId: string;
|
||||
rightId: string;
|
||||
leftAspectRatio: number;
|
||||
rightAspectRatio: number;
|
||||
leftId: string | null;
|
||||
rightId: string | null;
|
||||
leftAspectRatio: number | null;
|
||||
rightAspectRatio: number | null;
|
||||
leftEloAfter: number;
|
||||
rightEloAfter: number;
|
||||
winningSide: "left" | "right";
|
||||
@@ -99,6 +100,7 @@ export async function getRecentMatches(
|
||||
.select({
|
||||
id: assets.id,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
active: assets.active,
|
||||
})
|
||||
.from(assets)
|
||||
.as("left");
|
||||
@@ -106,6 +108,7 @@ export async function getRecentMatches(
|
||||
.select({
|
||||
id: assets.id,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
active: assets.active,
|
||||
})
|
||||
.from(assets)
|
||||
.as("right");
|
||||
@@ -118,6 +121,8 @@ export async function getRecentMatches(
|
||||
rightId: pairings.assetBId,
|
||||
leftAspectRatio: left.aspectRatio,
|
||||
rightAspectRatio: right.aspectRatio,
|
||||
leftActive: left.active,
|
||||
rightActive: right.active,
|
||||
winnerEloBefore: matches.winnerEloBefore,
|
||||
loserEloBefore: matches.loserEloBefore,
|
||||
createdAt: matches.createdAt,
|
||||
@@ -138,10 +143,10 @@ export async function getRecentMatches(
|
||||
const winningSide = r.winnerId === r.leftId ? "left" : "right";
|
||||
return {
|
||||
id: r.id,
|
||||
leftId: r.leftId,
|
||||
rightId: r.rightId,
|
||||
leftAspectRatio: r.leftAspectRatio,
|
||||
rightAspectRatio: r.rightAspectRatio,
|
||||
leftId: r.leftActive ? r.leftId : null,
|
||||
rightId: r.rightActive ? r.rightId : null,
|
||||
leftAspectRatio: r.leftActive ? r.leftAspectRatio : null,
|
||||
rightAspectRatio: r.rightActive ? r.rightAspectRatio : null,
|
||||
leftEloAfter: winningSide === "left" ? newWinnerElo : newLoserElo,
|
||||
rightEloAfter: winningSide === "right" ? newWinnerElo : newLoserElo,
|
||||
winningSide,
|
||||
@@ -155,10 +160,10 @@ export async function getRecentMatches(
|
||||
|
||||
export interface UpsetEntry {
|
||||
id: number;
|
||||
winnerId: string;
|
||||
loserId: string;
|
||||
winnerAspectRatio: number;
|
||||
loserAspectRatio: number;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
winnerAspectRatio: number | null;
|
||||
loserAspectRatio: number | null;
|
||||
winnerEloBefore: number;
|
||||
loserEloBefore: number;
|
||||
eloDiff: number;
|
||||
@@ -170,6 +175,7 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
|
||||
.select({
|
||||
id: assets.id,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
active: assets.active,
|
||||
})
|
||||
.from(assets)
|
||||
.as("winner");
|
||||
@@ -177,6 +183,7 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
|
||||
.select({
|
||||
id: assets.id,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
active: assets.active,
|
||||
})
|
||||
.from(assets)
|
||||
.as("loser");
|
||||
@@ -189,6 +196,8 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
|
||||
loserId: matches.loserId,
|
||||
winnerAspectRatio: winner.aspectRatio,
|
||||
loserAspectRatio: loser.aspectRatio,
|
||||
winnerActive: winner.active,
|
||||
loserActive: loser.active,
|
||||
winnerEloBefore: matches.winnerEloBefore,
|
||||
loserEloBefore: matches.loserEloBefore,
|
||||
eloDiff: sql<number>`${matches.loserEloBefore} - ${matches.winnerEloBefore}`,
|
||||
@@ -201,7 +210,17 @@ export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
|
||||
.orderBy(desc(sql`${matches.loserEloBefore} - ${matches.winnerEloBefore}`))
|
||||
.limit(limit);
|
||||
|
||||
return rows;
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
winnerId: r.winnerActive ? r.winnerId : null,
|
||||
loserId: r.loserActive ? r.loserId : null,
|
||||
winnerAspectRatio: r.winnerActive ? r.winnerAspectRatio : null,
|
||||
loserAspectRatio: r.loserActive ? r.loserAspectRatio : null,
|
||||
winnerEloBefore: r.winnerEloBefore,
|
||||
loserEloBefore: r.loserEloBefore,
|
||||
eloDiff: r.eloDiff,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- Most Controversial ----------
|
||||
@@ -223,6 +242,7 @@ export async function getMostControversial(
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(eq(assets.active, true))
|
||||
.orderBy(
|
||||
sql`ABS(CASE WHEN ${assets.matchesProvisional} > 0 THEN CAST(${assets.winsProvisional} AS REAL) / ${assets.matchesProvisional} ELSE 0.5 END - 0.5)`,
|
||||
desc(assets.matchesProvisional),
|
||||
@@ -256,6 +276,7 @@ export async function getEloDistribution(
|
||||
count: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.active, true))
|
||||
.groupBy(
|
||||
sql`CAST(${assets.eloProvisional} / ${bucketSize} AS INTEGER) * ${bucketSize}`,
|
||||
)
|
||||
@@ -306,7 +327,7 @@ export async function getLensStats(): Promise<LensStats[]> {
|
||||
})
|
||||
.from(lenses)
|
||||
.innerJoin(assets, eq(assets.lensId, lenses.id))
|
||||
.where(isNotNull(assets.lensId))
|
||||
.where(and(isNotNull(assets.lensId), eq(assets.active, true)))
|
||||
.groupBy(lenses.id)
|
||||
.orderBy(desc(sql`AVG(${assets.eloProvisional})`));
|
||||
|
||||
@@ -320,7 +341,7 @@ export async function getLensStats(): Promise<LensStats[]> {
|
||||
elo: assets.eloProvisional,
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.lensId, row.lensId))
|
||||
.where(and(eq(assets.lensId, row.lensId), eq(assets.active, true)))
|
||||
.orderBy(desc(assets.eloProvisional))
|
||||
.limit(1);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user