diff --git a/common/badge.ts b/common/badge.ts new file mode 100644 index 00000000..e46de538 --- /dev/null +++ b/common/badge.ts @@ -0,0 +1,121 @@ +import { User } from './user' + +export type Achievement = { + totalBadges: number + badges: Badge[] +} + +export type Badge = { + type: BadgeTypes + createdTime: number + data: { [key: string]: any } +} + +export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR' + +export type ProvenCorrectBadgeData = { + type: 'PROVEN_CORRECT' + data: { + contractSlug: string + contractCreatorUsername: string + contractTitle: string + commentId: string + betAmount: number + } +} + +export type MarketCreatorBadgeData = { + type: 'MARKET_CREATOR' + data: { + totalContractsCreated: number + } +} + +export type StreakerBadgeData = { + type: 'STREAKER' + data: { + totalBettingStreak: number + } +} + +export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData +export type StreakerBadge = Badge & StreakerBadgeData +export type MarketCreatorBadge = Badge & MarketCreatorBadgeData + +export const provenCorrectRarityThresholds = [1, 1000, 10000] +const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => { + const { betAmount } = badge.data + const thresholdArray = provenCorrectRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (betAmount == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const streakerBadgeRarityThresholds = [1, 25, 100] +const calculateStreakerBadgeRarity = (badge: StreakerBadge) => { + const { totalBettingStreak } = badge.data + const thresholdArray = streakerBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalBettingStreak == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const marketMakerBadgeRarityThresholds = [1, 25, 150] +const calculateMarketMakerBadgeRarity = (badge: MarketCreatorBadge) => { + const { totalContractsCreated } = badge.data + const thresholdArray = marketMakerBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalContractsCreated == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export type rarities = 'common' | 'bronze' | 'silver' | 'gold' + +const rarityRanks: { [key: number]: rarities } = { + 0: 'common', + 1: 'bronze', + 2: 'silver', + 3: 'gold', +} + +export const calculateBadgeRarity = (badge: Badge) => { + switch (badge.type) { + case 'PROVEN_CORRECT': + return rarityRanks[ + calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge) + ] + case 'MARKET_CREATOR': + return rarityRanks[ + calculateMarketMakerBadgeRarity(badge as MarketCreatorBadge) + ] + case 'STREAKER': + return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)] + default: + return rarityRanks[0] + } +} + +export const calculateTotalUsersBadges = (user: User) => { + const { achievements } = user + if (!achievements) return 0 + return ( + (achievements.marketCreator?.totalBadges ?? 0) + + (achievements.provenCorrect?.totalBadges ?? 0) + + (achievements.streaker?.totalBadges ?? 0) + ) +} diff --git a/common/scoring.ts b/common/scoring.ts index 4ef46534..fbff1d7d 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -1,8 +1,9 @@ -import { groupBy, sumBy, mapValues } from 'lodash' +import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash' import { Bet } from './bet' -import { getContractBetMetrics } from './calculate' +import { getContractBetMetrics, resolvedPayout } from './calculate' import { Contract } from './contract' +import { ContractComment } from './comment' export function scoreCreators(contracts: Contract[]) { const creatorScore = mapValues( @@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) { } export function scoreUsersByContract(contract: Contract, bets: Bet[]) { - const betsByUser = groupBy(bets, bet => bet.userId) - return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit) + const betsByUser = groupBy(bets, (bet) => bet.userId) + return mapValues( + betsByUser, + (bets) => getContractBetMetrics(contract, bets).profit + ) } export function addUserScores( @@ -43,3 +47,45 @@ export function addUserScores( dest[userId] += score } } + +export function scoreCommentorsAndBettors( + contract: Contract, + bets: Bet[], + comments: ContractComment[] +) { + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = betsById[topBetId]?.userName + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + + return { + topCommentId, + topBetId, + topBettor, + profitById, + commentsById, + betsById, + } +} diff --git a/common/user.ts b/common/user.ts index b490ab0c..0570f664 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,5 +1,6 @@ import { notification_preferences } from './user-notification-preferences' -import { ENV_CONFIG } from 'common/envs/constants' +import { ENV_CONFIG } from './envs/constants' +import { Achievement } from './badge' export type User = { id: string @@ -49,6 +50,12 @@ export type User = { hasSeenContractFollowModal?: boolean freeMarketsCreated?: number isBannedFromPosting?: boolean + + achievements?: { + provenCorrect?: Achievement + marketCreator?: Achievement + streaker?: Achievement + } } export type PrivateUser = { diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index ce75f0fe..46f57095 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -28,6 +28,10 @@ import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes' import { addHouseLiquidity } from './add-liquidity' import { DAY_MS } from '../../common/util/time' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' +import { + StreakerBadge, + streakerBadgeRarityThresholds, +} from '../../common/badge' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -141,6 +145,7 @@ const updateBettingStreak = async ( newBettingStreak, eventId ) + await handleBettingStreakBadgeAward(user, newBettingStreak) } const updateUniqueBettorsAndGiveCreatorBonus = async ( @@ -276,3 +281,38 @@ const notifyFills = async ( const currentDateBettingStreakResetTime = () => { return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) } + +async function handleBettingStreakBadgeAward( + user: User, + newBettingStreak: number +) { + const alreadyHasBadgeForFirstStreak = + user.achievements?.streaker?.badges.some( + (badge) => badge.data.totalBettingStreak === 1 + ) + + if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return + + if (newBettingStreak in streakerBadgeRarityThresholds) { + const badge = { + type: 'STREAKER', + data: { + totalBettingStreak: newBettingStreak, + }, + createdTime: Date.now(), + } as StreakerBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + streaker: { + totalBadges: (user.achievements?.streaker?.totalBadges ?? 0) + 1, + badges: [...(user.achievements?.streaker?.badges ?? []), badge], + }, + }, + }) + } +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index b613142b..2cbe2dbd 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,11 +1,17 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import { getUser, getValues } from './utils' import { createNewContractNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' import { addUserToContractFollowers } from './follow-market' +import { User } from '../../common/user' +import * as admin from 'firebase-admin' +import { + MarketCreatorBadge, + marketMakerBadgeRarityThresholds, +} from '../../common/badge' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -28,4 +34,43 @@ export const onCreateContract = functions richTextToString(desc), mentioned ) + await handleMarketCreatorBadgeAward(contractCreator) }) + +const firestore = admin.firestore() + +async function handleMarketCreatorBadgeAward(contractCreator: User) { + // get all contracts by user and calculate size of array + const contracts = await getValues( + firestore + .collection(`contracts`) + .where('creatorId', '==', contractCreator.id) + ) + if (contracts.length in marketMakerBadgeRarityThresholds) { + const badge = { + type: 'MARKET_CREATOR', + data: { + totalContractsCreated: contracts.length, + }, + createdTime: Date.now(), + } as MarketCreatorBadge + // update user + await firestore + .collection('users') + .doc(contractCreator.id) + .update({ + achievements: { + ...contractCreator.achievements, + marketCreator: { + totalBadges: + (contractCreator.achievements?.marketCreator?.totalBadges ?? 0) + + 1, + badges: [ + ...(contractCreator.achievements?.marketCreator?.badges ?? []), + badge, + ], + }, + }, + }) + } +} diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 5e2a94c0..1dcd07d0 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,7 +1,12 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import { getUser, getValues } from './utils' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' +import { Bet } from '../../common/bet' +import * as admin from 'firebase-admin' +import { ContractComment } from '../../common/comment' +import { scoreCommentorsAndBettors } from '../../common/scoring' +import { ProvenCorrectBadge } from '../../common/badge' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') @@ -14,8 +19,9 @@ export const onUpdateContract = functions.firestore const previousValue = change.before.data() as Contract - // Resolution is handled in resolve-market.ts - if (!previousValue.isResolved && contract.isResolved) return + // Notifications for market resolution are also handled in resolve-market.ts + if (!previousValue.isResolved && contract.isResolved) + return await handleResolvedContract(contract) if ( previousValue.closeTime !== contract.closeTime || @@ -42,3 +48,55 @@ export const onUpdateContract = functions.firestore ) } }) +const firestore = admin.firestore() + +async function handleResolvedContract(contract: Contract) { + // get all bets on this contract + const bets = await getValues( + firestore.collection(`contracts/${contract.id}/bets`) + ) + + // get comments on this contract + const comments = await getValues( + firestore.collection(`contracts/${contract.id}/comments`) + ) + + const { topCommentId, profitById, commentsById, betsById } = + scoreCommentorsAndBettors(contract, bets, comments) + if (topCommentId && profitById[topCommentId] > 0) { + // award proven correct badge to user + const comment = commentsById[topCommentId] + const bet = betsById[topCommentId] + + const user = await getUser(comment.userId) + if (!user) return + const newProvenCorrectBadge = { + createdTime: Date.now(), + type: 'PROVEN_CORRECT', + data: { + contractSlug: contract.slug, + contractCreatorUsername: contract.creatorUsername, + commentId: comment.id, + betAmount: bet.amount, + contractTitle: contract.question, + }, + } as ProvenCorrectBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + provenCorrect: { + totalBadges: + (user.achievements?.provenCorrect?.totalBadges ?? 0) + 1, + badges: [ + ...(user.achievements?.provenCorrect?.badges ?? []), + newProvenCorrectBadge, + ], + }, + }, + }) + } +} diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index fec6744d..bb91eea5 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -3,7 +3,7 @@ import { ContractComment } from 'common/comment' import { resolvedPayout } from 'common/calculate' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' +import { groupBy, mapValues, sumBy, sortBy } from 'lodash' import { useState, useMemo, useEffect } from 'react' import { CommentTipMap } from 'web/hooks/use-tip-txns' import { listUsers, User } from 'web/lib/firebase/users' @@ -13,6 +13,7 @@ import { Spacer } from '../layout/spacer' import { Leaderboard } from '../leaderboard' import { Title } from '../title' import { BETTORS } from 'common/user' +import { scoreCommentorsAndBettors } from 'common/scoring' export function ContractLeaderboard(props: { contract: Contract @@ -69,33 +70,14 @@ export function ContractTopTrades(props: { tips: CommentTipMap }) { const { contract, bets, comments, tips } = props - const commentsById = keyBy(comments, 'id') - const betsById = keyBy(bets, 'id') - - // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit - // Otherwise, we record the profit at resolution time - const profitById: Record = {} - for (const bet of bets) { - if (bet.sale) { - const originalBet = betsById[bet.sale.betId] - const profit = bet.sale.amount - originalBet.amount - profitById[bet.id] = profit - profitById[originalBet.id] = profit - } else { - profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount - } - } - - // Now find the betId with the highest profit - const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id - const topBettor = betsById[topBetId]?.userName - - // And also the commentId of the comment with the highest profit - const topCommentId = sortBy( - comments, - (c) => c.betId && -profitById[c.betId] - )[0]?.id - + const { + topCommentId, + topBetId, + topBettor, + profitById, + commentsById, + betsById, + } = scoreCommentorsAndBettors(contract, bets, comments) return (
{topCommentId && profitById[topCommentId] > 0 && ( diff --git a/web/components/profile/badges-modal.tsx b/web/components/profile/badges-modal.tsx new file mode 100644 index 00000000..73cd607a --- /dev/null +++ b/web/components/profile/badges-modal.tsx @@ -0,0 +1,217 @@ +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { User } from 'common/user' +import clsx from 'clsx' +import { + Badge, + calculateBadgeRarity, + MarketCreatorBadge, + ProvenCorrectBadge, + rarities, + StreakerBadge, +} from 'common/badge' +import { groupBy } from 'lodash' +import { Row } from 'web/components/layout/row' +import { SiteLink } from 'web/components/site-link' +import { contractPathWithoutContract } from 'web/lib/firebase/contracts' +import { Tooltip } from 'web/components/tooltip' +const goldClassName = 'text-amber-400' +const silverClassName = 'text-gray-500' +const bronzeClassName = 'text-amber-900' +export function BadgesModal(props: { + isOpen: boolean + setOpen: (open: boolean) => void + user: User +}) { + const { isOpen, setOpen, user } = props + const { provenCorrect, marketCreator, streaker } = user.achievements ?? {} + const badges = [ + ...(provenCorrect?.badges ?? []), + ...(streaker?.badges ?? []), + ...(marketCreator?.badges ?? []), + ] + + // group badges by their rarities + const badgesByRarity = groupBy(badges, (badge) => calculateBadgeRarity(badge)) + + return ( + + + 🏅 + {user.name + "'s"} badges + + + + Bronze + + {badgesByRarity['bronze'] ? ( + badgesByRarity['bronze'].map((badge, i) => ( + + )) + ) : ( + None yet + )} + + + + Silver + + {badgesByRarity['silver'] ? ( + badgesByRarity['silver'].map((badge, i) => ( + + )) + ) : ( + None yet + )} + + + + Gold + + {badgesByRarity['gold'] ? ( + badgesByRarity['gold'].map((badge, i) => ( + + )) + ) : ( + None yet + )} + + + + + + ) +} + +function BadgeToItem(props: { badge: Badge; rarity: rarities }) { + const { badge, rarity } = props + if (badge.type === 'PROVEN_CORRECT') + return ( + + ) + else if (badge.type === 'STREAKER') + return + else if (badge.type === 'MARKET_CREATOR') + return ( + + ) + else return null +} + +function ProvenCorrectBadgeItem(props: { + badge: ProvenCorrectBadge + rarity: rarities +}) { + const { badge, rarity } = props + const { betAmount, contractSlug, contractCreatorUsername } = badge.data + return ( + + + + + Proven Correct + + + + ) +} +function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) { + const { badge, rarity } = props + const { totalBettingStreak } = badge.data + return ( + + + 1 ? 's' : ''} in a row`} + > + + Prediction Streak + + + + ) +} +function MarketCreatorBadgeItem(props: { + badge: MarketCreatorBadge + rarity: rarities +}) { + const { badge, rarity } = props + const { totalContractsCreated } = badge.data + return ( + + + 1 ? 's' : '' + }`} + > + + Market Maker + + + + ) +} +function Medal(props: { rarity: rarities }) { + const { rarity } = props + return ( + + {rarity === 'gold' ? '🥇' : rarity === 'silver' ? '🥈' : '🥉'} + + ) +} diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 4d1d63be..306a839c 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -13,7 +13,7 @@ import clsx from 'clsx' export function BettingStreakModal(props: { isOpen: boolean setOpen: (open: boolean) => void - currentUser?: User | null + currentUser: User | null | undefined }) { const { isOpen, setOpen, currentUser } = props const missingStreak = currentUser && !hasCompletedStreakToday(currentUser) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 2b24fa60..ed42927a 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -37,6 +37,8 @@ import { LoansModal } from './profile/loans-modal' import { UserLikesButton } from 'web/components/profile/user-likes-button' import { PAST_BETS } from 'common/user' import { capitalize } from 'lodash' +import { BadgesModal } from 'web/components/profile/badges-modal' +import { calculateTotalUsersBadges } from 'common/badge' export function UserPage(props: { user: User }) { const { user } = props @@ -46,6 +48,7 @@ export function UserPage(props: { user: User }) { const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const [showConfetti, setShowConfetti] = useState(false) const [showBettingStreakModal, setShowBettingStreakModal] = useState(false) + const [showBadgesModal, setShowBadgesModal] = useState(false) const [showLoansModal, setShowLoansModal] = useState(false) useEffect(() => { @@ -90,9 +93,12 @@ export function UserPage(props: { user: User }) { setOpen={setShowBettingStreakModal} currentUser={currentUser} /> - {showLoansModal && ( - - )} + + {/* Banner image up top, with an circle avatar overlaid */}
next loan + setShowBadgesModal(true)} + > + 🏅 {calculateTotalUsersBadges(user)} + badges +