From 62f4fb6faf9d9fbc33745690c88efc6d5422f6b1 Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Wed, 6 May 2026 20:42:56 -0400 Subject: [PATCH 1/9] hide some logged in functions on the pinned welcome post; fix logged in guessing --- .../entries/drawing-post/DrawingPost.tsx | 43 +++++- src/client/entries/pinned-post/PinnedPost.tsx | 6 +- src/server/core/redis.ts | 3 +- src/server/services/drawing.test.ts | 4 +- src/server/services/posts/drawing.ts | 26 ++-- src/server/services/progression.ts | 134 ++++++++++-------- src/server/trpc/context.ts | 3 + src/server/trpc/router.test.ts | 41 ++++++ src/server/trpc/router.ts | 26 +++- src/shared/schema/pixelary.ts | 7 + 10 files changed, 211 insertions(+), 82 deletions(-) diff --git a/src/client/entries/drawing-post/DrawingPost.tsx b/src/client/entries/drawing-post/DrawingPost.tsx index 2da1e0e..bfd2818 100644 --- a/src/client/entries/drawing-post/DrawingPost.tsx +++ b/src/client/entries/drawing-post/DrawingPost.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { GuessView } from './_components/GuessView'; import { ResultsView } from './_components/ResultsView'; @@ -16,9 +16,44 @@ import type { DrawingPostData } from '@src/shared/schema'; type DrawingState = 'unsolved' | 'guessing' | 'solved' | 'skipped' | 'author'; +function getAnonymousPlayerId( + userId: string | null | undefined +): string | undefined { + if (userId) { + return undefined; + } + + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? undefined; + if (contextLoid) { + return contextLoid; + } + + try { + const key = 'pixelary:anonymous-player-id'; + const existing = window.localStorage.getItem(key); + if (existing) { + return existing; + } + + const generated = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? `anon_${crypto.randomUUID()}` + : `anon_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; + window.localStorage.setItem(key, generated); + return generated; + } catch { + return undefined; + } +} + export function DrawingPost() { const postData = getPostData(); const currentPostId = context.postId; + const loid = useMemo( + () => getAnonymousPlayerId(context.userId), + [context.userId] + ); const { error: showErrorToast, success } = useToastHelpers(); // If postData is missing, try to trigger migration via API @@ -239,6 +274,7 @@ export function DrawingPost() { const result = await submitGuess.mutateAsync({ postId: currentPostId, guess, + ...(!context.userId && loid ? { loid } : {}), }); // Only change state after server confirms @@ -261,7 +297,10 @@ export function DrawingPost() { // currentPostId is always present for drawing posts try { - await skipPost.mutateAsync({ postId: currentPostId }); + await skipPost.mutateAsync({ + postId: currentPostId, + ...(!context.userId && loid ? { loid } : {}), + }); setCurrentState('skipped'); } catch (err) { showErrorToast('Failed to skip post. Please try again.', { diff --git a/src/client/entries/pinned-post/PinnedPost.tsx b/src/client/entries/pinned-post/PinnedPost.tsx index 85a857c..98c24a4 100644 --- a/src/client/entries/pinned-post/PinnedPost.tsx +++ b/src/client/entries/pinned-post/PinnedPost.tsx @@ -5,6 +5,7 @@ import { MyRewards } from './_components/MyRewards'; import { LevelDetails } from './_components/LevelDetails'; import { Menu } from './_components/Menu'; import { trpc } from '@client/trpc/client'; +import { context } from '@devvit/web/client'; type Page = | 'menu' @@ -16,11 +17,13 @@ type Page = export function PinnedPost() { const [page, setPage] = useState('menu'); const utils = trpc.useUtils(); + const isLoggedIn = Boolean(context.userId); // Prefetch drawings optimistically for maximum performance trpc.app.user.getMyArtPage.useQuery( { limit: 20 }, { + enabled: isLoggedIn, staleTime: 60000, // Cache for 1 minute refetchOnWindowFocus: false, // Don't refetch on window focus } @@ -41,6 +44,7 @@ export function PinnedPost() { refetchOnWindowFocus: false, }); trpc.app.rewards.getEffectiveBonuses.useQuery(undefined, { + enabled: isLoggedIn, staleTime: 10000, refetchOnWindowFocus: false, }); @@ -50,7 +54,7 @@ export function PinnedPost() { } function goToPage(page: Page) { - if (page === 'my-rewards') { + if (page === 'my-rewards' && isLoggedIn) { // Proactively load inventory/effects to avoid flashes in the modal void utils.app.rewards.getInventory.prefetch(); void utils.app.rewards.getActiveEffects.prefetch(); diff --git a/src/server/core/redis.ts b/src/server/core/redis.ts index a4fa029..8586184 100644 --- a/src/server/core/redis.ts +++ b/src/server/core/redis.ts @@ -65,6 +65,7 @@ export const REDIS_KEYS = { // Progression system scores: () => 'scores', + scoresGuest: () => 'scores:guest', // Flair templates flairTemplates: { @@ -104,7 +105,7 @@ export const REDIS_KEYS = { userArtItem: (userId: T2, compositeId: string) => `user:art:item:${userId}:${compositeId}`, // HASH snapshot for listing hydration // Rate limit keys - rateGuess: (userId: T2) => `rate:guess:${userId}`, + rateGuess: (userId: string) => `rate:guess:${userId}`, rateVote: (userId: T2) => `rate:vote:${userId}`, rateSubmit: (userId: T2) => `rate:submit:${userId}`, diff --git a/src/server/services/drawing.test.ts b/src/server/services/drawing.test.ts index 7356070..daa6535 100644 --- a/src/server/services/drawing.test.ts +++ b/src/server/services/drawing.test.ts @@ -85,7 +85,7 @@ describe('Drawing Service', () => { const result = await submitGuess({ postId: 't3_test123', - userId: 't2_testuser', + playerId: 't2_testuser', guess: 'test', }); @@ -109,7 +109,7 @@ describe('Drawing Service', () => { const result = await submitGuess({ postId: 't3_test123', - userId: 't2_testuser', + playerId: 't2_testuser', guess: 'wrong', }); diff --git a/src/server/services/posts/drawing.ts b/src/server/services/posts/drawing.ts index fddd38b..5f65afc 100644 --- a/src/server/services/posts/drawing.ts +++ b/src/server/services/posts/drawing.ts @@ -249,9 +249,9 @@ export async function getDrawings( ); } -export async function skipDrawing(postId: T3, userId: T2): Promise { +export async function skipDrawing(postId: T3, playerId: string): Promise { const key = REDIS_KEYS.drawingSkips(postId); - await redis.zAdd(key, { member: userId, score: Date.now() }); + await redis.zAdd(key, { member: playerId, score: Date.now() }); } export async function getDrawingStats( @@ -405,16 +405,16 @@ export async function getUserDrawingsWithData( export async function submitGuess(options: { postId: T3; - userId: T2; + playerId: string; guess: string; }): Promise<{ correct: boolean; points: number }> { - const { postId, userId, guess } = options; + const { postId, playerId, guess } = options; const empty = { correct: false, points: 0 }; - if (await isRateLimited(REDIS_KEYS.rateGuess(userId), 3, 1)) return empty; + if (await isRateLimited(REDIS_KEYS.rateGuess(playerId), 3, 1)) return empty; const [drawingData, solved, skipped] = await Promise.all([ getCachedDrawingData(postId), - redis.zScore(REDIS_KEYS.drawingSolves(postId), userId), - redis.zScore(REDIS_KEYS.drawingSkips(postId), userId), + redis.zScore(REDIS_KEYS.drawingSolves(postId), playerId), + redis.zScore(REDIS_KEYS.drawingSkips(postId), playerId), ]); const word = drawingData.word; const drawingNormalizedWord = drawingData.normalizedWord; @@ -432,17 +432,17 @@ export async function submitGuess(options: { const correct = normalizedGuess === normalizedWord; const now = Date.now(); const redisOperations: Array> = [ - redis.zIncrBy(REDIS_KEYS.drawingAttempts(postId), userId, 1), + redis.zIncrBy(REDIS_KEYS.drawingAttempts(postId), playerId, 1), redis.zIncrBy(REDIS_KEYS.wordDrawings(word), postId, 1), redis.zIncrBy(REDIS_KEYS.drawingGuesses(postId), normalizedGuess, 1), ]; if (correct) { redisOperations.push( redis.zAdd(REDIS_KEYS.drawingSolves(postId), { - member: userId, + member: playerId, score: now, }), - incrementScore(userId, GUESSER_REWARD_SOLVE), + incrementScore(playerId, GUESSER_REWARD_SOLVE), incrementScore(authorId, AUTHOR_REWARD_CORRECT_GUESS) ); } @@ -676,11 +676,11 @@ function generateLiveStatsSection( export async function getUserDrawingStatus( postId: T3, - userId: T2 + playerId: string ): Promise<{ solved: boolean; skipped: boolean; guessCount: number }> { const [solved, skipped, guesses] = await Promise.all([ - redis.zScore(REDIS_KEYS.drawingSolves(postId), userId), - redis.zScore(REDIS_KEYS.drawingSkips(postId), userId), + redis.zScore(REDIS_KEYS.drawingSolves(postId), playerId), + redis.zScore(REDIS_KEYS.drawingSkips(postId), playerId), redis.zRange(REDIS_KEYS.drawingGuesses(postId), 0, -1, { by: 'rank' }), ]); const guessesTyped2 = guesses as Array<{ member: string; score: number }>; diff --git a/src/server/services/progression.ts b/src/server/services/progression.ts index 71c0ac5..ba8bd56 100644 --- a/src/server/services/progression.ts +++ b/src/server/services/progression.ts @@ -3,6 +3,7 @@ import { REDIS_KEYS } from '../core/redis'; import { getUsername } from '../core/user'; import { getLevelByScore as getLevelByScoreUtil } from '@shared/utils/progression'; import type { T2 } from '@devvit/shared-types/tid.js'; +import { isT2 } from '@devvit/shared-types/tid.js'; import type { Level } from '@shared/types'; import { REALTIME_CHANNELS } from '@server/core/realtime'; @@ -41,11 +42,12 @@ export async function getLeaderboard( cursor, cursor + limit - 1, { reverse, by } - )) as Array<{ member: T2; score: number }>; + )) as Array<{ member: string; score: number }>; + const userEntries = entries.filter((entry) => isT2(entry.member)); // Hydrate with usernames (getUsername is already cached for 30 days) const data = await Promise.all( - entries.map(async (entry, index) => { + userEntries.map(async (entry, index) => { const username = await getUsername(entry.member); return { username: username, @@ -62,14 +64,18 @@ export async function getLeaderboard( }; } +function getScoresKey(userId: string): string { + return isT2(userId) ? REDIS_KEYS.scores() : REDIS_KEYS.scoresGuest(); +} + /** * Get the score for a user * @param userId - The user ID * @returns The user score */ -export async function getScore(userId: T2): Promise { - const key = REDIS_KEYS.scores(); +export async function getScore(userId: string): Promise { + const key = getScoresKey(userId); const score = await redis.zScore(key, userId); return score ?? 0; // Default to 0 if user not found } @@ -81,26 +87,29 @@ export async function getScore(userId: T2): Promise { * @returns The score that was set */ -export async function setScore(userId: T2, score: number): Promise { - const key = REDIS_KEYS.scores(); +export async function setScore(userId: string, score: number): Promise { + const key = getScoresKey(userId); const oldScore = await getScore(userId); await redis.zAdd(key, { member: userId, score }); const level = getLevelByScore(score); const oldLevel = getLevelByScore(oldScore); - - // Update claimed level if user leveled up - if (level.rank > oldLevel.rank) { - // Keep the old claimed level so the modal will show - // Don't update it here - let the user claim the new level - } else { - // User stayed at same level or went down, update claimed level to match - const newClaimedKey = REDIS_KEYS.userLevelUpClaim(userId); - await redis.set(newClaimedKey, level.rank.toString()); + const isLoggedInUser = isT2(userId); + + if (isLoggedInUser) { + // Update claimed level if user leveled up + if (level.rank > oldLevel.rank) { + // Keep the old claimed level so the modal will show + // Don't update it here - let the user claim the new level + } else { + // User stayed at same level or went down, update claimed level to match + const newClaimedKey = REDIS_KEYS.userLevelUpClaim(userId); + await redis.set(newClaimedKey, level.rank.toString()); + } } const didUserLevelUp = level.min > oldScore; - if (didUserLevelUp) { + if (didUserLevelUp && isLoggedInUser) { await scheduler.runJob({ name: 'USER_LEVEL_UP', data: { @@ -125,15 +134,17 @@ export async function setScore(userId: T2, score: number): Promise { } // Always notify client that score changed (covers level down or no change) - try { - await realtime.send(REALTIME_CHANNELS.userLevelUp(userId), { - type: 'score_changed', - level: level.rank, - score, - timestamp: Date.now(), - }); - } catch { - // Non-blocking realtime error + if (isLoggedInUser) { + try { + await realtime.send(REALTIME_CHANNELS.userLevelUp(userId), { + type: 'score_changed', + level: level.rank, + score, + timestamp: Date.now(), + }); + } catch { + // Non-blocking realtime error + } } return score; @@ -147,21 +158,24 @@ export async function setScore(userId: T2, score: number): Promise { */ export async function incrementScore( - userId: T2, + userId: string, amount: number ): Promise { - const key = REDIS_KEYS.scores(); + const key = getScoresKey(userId); + const isLoggedInUser = isT2(userId); // Apply active score multiplier (non-stacking; highest wins) - try { - const { getEffectiveScoreMultiplier } = await import( - '../services/rewards/consumables' - ); - const multiplier = await getEffectiveScoreMultiplier(userId); - if (multiplier > 1) { - amount = Math.floor(amount * multiplier); + if (isLoggedInUser) { + try { + const { getEffectiveScoreMultiplier } = await import( + '../services/rewards/consumables' + ); + const multiplier = await getEffectiveScoreMultiplier(userId); + if (multiplier > 1) { + amount = Math.floor(amount * multiplier); + } + } catch { + // If rewards provider fails, continue without multiplier } - } catch { - // If rewards provider fails, continue without multiplier } const oldScore = await getScore(userId); const score = await redis.zIncrBy(key, userId, amount); @@ -169,21 +183,23 @@ export async function incrementScore( const oldLevel = getLevelByScore(oldScore); const didUserLevelUp = level.min > oldScore; - // Update claimed level if user leveled up - if (level.rank > oldLevel.rank && didUserLevelUp) { - // Keep the old claimed level so the modal will show - // Don't update it here - let the user claim the new level - } else if (level.rank === oldLevel.rank) { - // User stayed at same level, update claimed level to match - const newClaimedKey = REDIS_KEYS.userLevelUpClaim(userId); - await redis.set(newClaimedKey, level.rank.toString()); - } else { - // User leveled down, update claimed level to the new lower level - const newClaimedKey = REDIS_KEYS.userLevelUpClaim(userId); - await redis.set(newClaimedKey, level.rank.toString()); + if (isLoggedInUser) { + // Update claimed level if user leveled up + if (level.rank > oldLevel.rank && didUserLevelUp) { + // Keep the old claimed level so the modal will show + // Don't update it here - let the user claim the new level + } else if (level.rank === oldLevel.rank) { + // User stayed at same level, update claimed level to match + const newClaimedKey = REDIS_KEYS.userLevelUpClaim(userId); + await redis.set(newClaimedKey, level.rank.toString()); + } else { + // User leveled down, update claimed level to the new lower level + const newClaimedKey = REDIS_KEYS.userLevelUpClaim(userId); + await redis.set(newClaimedKey, level.rank.toString()); + } } - if (didUserLevelUp) { + if (didUserLevelUp && isLoggedInUser) { await scheduler.runJob({ name: 'USER_LEVEL_UP', data: { @@ -208,15 +224,17 @@ export async function incrementScore( } // Always notify client that score changed (covers level down or no change) - try { - await realtime.send(REALTIME_CHANNELS.userLevelUp(userId), { - type: 'score_changed', - level: level.rank, - score, - timestamp: Date.now(), - }); - } catch { - // Non-blocking realtime error + if (isLoggedInUser) { + try { + await realtime.send(REALTIME_CHANNELS.userLevelUp(userId), { + type: 'score_changed', + level: level.rank, + score, + timestamp: Date.now(), + }); + } catch { + // Non-blocking realtime error + } } return score; diff --git a/src/server/trpc/context.ts b/src/server/trpc/context.ts index e4ba23c..8d578ce 100644 --- a/src/server/trpc/context.ts +++ b/src/server/trpc/context.ts @@ -5,12 +5,15 @@ import type { T2, T3, T5 } from '@devvit/shared-types/tid.js'; export async function createContext() { const username = await reddit.getCurrentUsername(); const { postId, subredditName, subredditId, postData, userId } = context; + const maybeLoid = Reflect.get(context as object, 'loid'); + const loid = typeof maybeLoid === 'string' ? maybeLoid : null; return { postId: (postId as T3 | null) ?? null, subredditName: subredditName ?? null, subredditId: (subredditId as T5 | null) ?? null, username: username ?? null, userId: (userId as T2 | null) ?? null, + loid: loid ?? null, postData: postData as PostData | null, reddit, scheduler, diff --git a/src/server/trpc/router.test.ts b/src/server/trpc/router.test.ts index d4aa732..7aa1983 100644 --- a/src/server/trpc/router.test.ts +++ b/src/server/trpc/router.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { appRouter } from './router'; import { redis } from '@devvit/web/server'; +import * as drawingService from '../services/posts/drawing'; import { createMockDictionary, createMockUserProfile, @@ -144,6 +145,7 @@ describe('appRouter', () => { subredditName: 'testsub', username: 'testuser', userId: 't2_user123' as `t2_${string}`, + loid: null as string | null, subredditId: 't5_testsub' as `t5_${string}`, postData: { type: 'drawing' as const, @@ -253,6 +255,45 @@ describe('appRouter', () => { expect(result).toBeTruthy(); }); + it('app.guess.submit works for logged-out users with loid', async () => { + const anonymousCaller = appRouter.createCaller({ + ...ctx, + userId: null, + loid: 'loid_test_123', + } as unknown as Parameters[0]); + + const result = await anonymousCaller.app.guess.submit({ + ...createMockGuessSubmitInput(), + loid: 'loid_test_123', + }); + + expect(result).toBeTruthy(); + expect(vi.mocked(drawingService.submitGuess)).toHaveBeenCalledWith( + expect.objectContaining({ + playerId: 'loid_test_123', + }) + ); + }); + + it('app.guess.skip works for logged-out users with loid', async () => { + const anonymousCaller = appRouter.createCaller({ + ...ctx, + userId: null, + loid: 'loid_test_123', + } as unknown as Parameters[0]); + + const result = await anonymousCaller.app.guess.skip({ + postId: 't3_test123', + loid: 'loid_test_123', + }); + + expect(result).toEqual({ success: true }); + expect(vi.mocked(drawingService.skipDrawing)).toHaveBeenCalledWith( + 't3_test123', + 'loid_test_123' + ); + }); + it('app.guess.getStats returns guess stats', async () => { const stats = await caller.app.guess.getStats({ postId: 't3_test123' }); expect(stats).toBeTruthy(); diff --git a/src/server/trpc/router.ts b/src/server/trpc/router.ts index ba7ef13..829daac 100644 --- a/src/server/trpc/router.ts +++ b/src/server/trpc/router.ts @@ -42,6 +42,7 @@ import { isAdmin, isModerator } from '@server/core/redis'; import { DrawingDataSchema, DrawingSubmitInputSchema, + GuessSkipInputSchema, GuessSubmitInputSchema, GuessStatsInputSchema, PostDataInputSchema, @@ -72,6 +73,19 @@ const t = initTRPC.context().create({ }, }); +function getPlayerId(ctx: Context, loid?: string): string | null { + if (ctx.userId) { + return ctx.userId; + } + if (ctx.loid) { + return ctx.loid; + } + if (loid) { + return loid; + } + return null; +} + export const appRouter = t.router({ system: t.router({ ping: t.procedure.query(() => ({ ok: true }) as const), @@ -454,7 +468,8 @@ export const appRouter = t.router({ submit: t.procedure .input(GuessSubmitInputSchema) .mutation(async ({ ctx, input }) => { - if (!ctx.userId) + const playerId = getPlayerId(ctx, input.loid); + if (!playerId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Must be logged in', @@ -464,7 +479,7 @@ export const appRouter = t.router({ const result = await submitGuess({ postId, - userId: ctx.userId, + playerId, guess: input.guess, }); @@ -481,16 +496,17 @@ export const appRouter = t.router({ }), skip: t.procedure - .input(PostDataInputSchema) + .input(GuessSkipInputSchema) .mutation(async ({ ctx, input }) => { - if (!ctx.userId) + const playerId = getPlayerId(ctx, input.loid); + if (!playerId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Must be logged in to skip post', }); assertT3(input.postId); const postId = input.postId; - await skipDrawing(postId, ctx.userId); + await skipDrawing(postId, playerId); return { success: true }; }), }), diff --git a/src/shared/schema/pixelary.ts b/src/shared/schema/pixelary.ts index 16b1dbb..d9fae4e 100644 --- a/src/shared/schema/pixelary.ts +++ b/src/shared/schema/pixelary.ts @@ -119,9 +119,16 @@ export type DrawingSubmitInput = z.infer; export const GuessSubmitInputSchema = z.object({ postId: z.string(), guess: z.string(), + loid: z.string().optional(), }); export type GuessSubmitInput = z.infer; +export const GuessSkipInputSchema = z.object({ + postId: z.string(), + loid: z.string().optional(), +}); +export type GuessSkipInput = z.infer; + export const DictionaryAddInputSchema = z.object({ word: z.string().min(1).max(50), }); From daf6c6f7d0deb2e163256eb94cf60fe1698feebc Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Wed, 6 May 2026 20:50:01 -0400 Subject: [PATCH 2/9] show solved status for logged out users --- .../entries/drawing-post/DrawingPost.tsx | 54 ++++++++++++++----- src/server/trpc/router.test.ts | 23 ++++++++ src/server/trpc/router.ts | 12 +++++ src/shared/schema/pixelary.ts | 6 +++ 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/client/entries/drawing-post/DrawingPost.tsx b/src/client/entries/drawing-post/DrawingPost.tsx index bfd2818..32b5c14 100644 --- a/src/client/entries/drawing-post/DrawingPost.tsx +++ b/src/client/entries/drawing-post/DrawingPost.tsx @@ -102,6 +102,16 @@ export function DrawingPost() { { postId: currentPostId }, { enabled: true } ); + const { data: anonymousDrawingStatus } = trpc.app.guess.getStatus.useQuery( + { + postId: currentPostId, + ...(loid ? { loid } : {}), + }, + { + enabled: !context.userId && !!effectivePostData && !!loid, + refetchOnWindowFocus: false, + } + ); const queryClient = useQueryClient(); const submitGuess = trpc.app.guess.submit.useMutation({ onSuccess: (data, variables) => { @@ -176,21 +186,41 @@ export function DrawingPost() { // Update state based on user's interaction with this post useEffect(() => { - if (userProfile && effectivePostData) { - if (isAuthor) { - setCurrentState('author'); + if (!effectivePostData) { + return; + } + + if (isAuthor) { + setCurrentState('author'); + return; + } + + if (context.userId) { + if (!userProfile) { + return; + } + + // Check logged-in user's server state + if (userProfile.skipped) { + setCurrentState('skipped'); + } else if (userProfile.solved) { + setCurrentState('solved'); } else { - // Check user's server state - if (userProfile.skipped) { - setCurrentState('skipped'); - } else if (userProfile.solved) { - setCurrentState('solved'); - } else { - setCurrentState('unsolved'); - } + setCurrentState('unsolved'); + } + return; + } + + if (anonymousDrawingStatus) { + if (anonymousDrawingStatus.skipped) { + setCurrentState('skipped'); + } else if (anonymousDrawingStatus.solved) { + setCurrentState('solved'); + } else { + setCurrentState('unsolved'); } } - }, [userProfile, effectivePostData, isAuthor]); + }, [userProfile, anonymousDrawingStatus, effectivePostData, isAuthor]); // Clear earned points when transitioning away from solved state useEffect(() => { diff --git a/src/server/trpc/router.test.ts b/src/server/trpc/router.test.ts index 7aa1983..f540776 100644 --- a/src/server/trpc/router.test.ts +++ b/src/server/trpc/router.test.ts @@ -55,6 +55,11 @@ vi.mock('../services/posts/drawing', () => ({ guessCount: 0, playerCount: 0, })), + getUserDrawingStatus: vi.fn(async () => ({ + solved: true, + skipped: false, + guessCount: 1, + })), getUserDrawings: vi.fn(async () => []), })); @@ -298,6 +303,24 @@ describe('appRouter', () => { const stats = await caller.app.guess.getStats({ postId: 't3_test123' }); expect(stats).toBeTruthy(); }); + + it('app.guess.getStatus returns anonymous status using loid', async () => { + const anonymousCaller = appRouter.createCaller({ + ...ctx, + userId: null, + loid: 'loid_test_123', + } as unknown as Parameters[0]); + + const status = await anonymousCaller.app.guess.getStatus({ + postId: 't3_test123', + loid: 'loid_test_123', + }); + + expect(status).toEqual({ solved: true, skipped: false, guessCount: 1 }); + expect( + vi.mocked(drawingService.getUserDrawingStatus) + ).toHaveBeenCalledWith('t3_test123', 'loid_test_123'); + }); it('app.slate.trackAction handles slate_posted with explicit postId', async () => { await expect( caller.app.slate.trackAction({ diff --git a/src/server/trpc/router.ts b/src/server/trpc/router.ts index 829daac..95913d6 100644 --- a/src/server/trpc/router.ts +++ b/src/server/trpc/router.ts @@ -45,6 +45,7 @@ import { GuessSkipInputSchema, GuessSubmitInputSchema, GuessStatsInputSchema, + GuessStatusInputSchema, PostDataInputSchema, } from '@shared/schema/pixelary'; import type { DrawingData } from '@shared/schema/drawing'; @@ -495,6 +496,17 @@ export const appRouter = t.router({ return result; }), + getStatus: t.procedure + .input(GuessStatusInputSchema) + .query(async ({ ctx, input }) => { + const playerId = getPlayerId(ctx, input.loid); + if (!playerId) { + return { solved: false, skipped: false, guessCount: 0 }; + } + assertT3(input.postId); + return await getUserDrawingStatus(input.postId, playerId); + }), + skip: t.procedure .input(GuessSkipInputSchema) .mutation(async ({ ctx, input }) => { diff --git a/src/shared/schema/pixelary.ts b/src/shared/schema/pixelary.ts index d9fae4e..c8c85f3 100644 --- a/src/shared/schema/pixelary.ts +++ b/src/shared/schema/pixelary.ts @@ -154,6 +154,12 @@ export const GuessStatsInputSchema = z.object({ }); export type GuessStatsInput = z.infer; +export const GuessStatusInputSchema = z.object({ + postId: z.string(), + loid: z.string().optional(), +}); +export type GuessStatusInput = z.infer; + export const PostDataInputSchema = z.object({ postId: z.string(), }); From 1e90a759d0295cf06d566dd9fbfbadbcf7c1ab75 Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Wed, 6 May 2026 20:58:18 -0400 Subject: [PATCH 3/9] update devvit version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6199511..002f412 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "deploy": "npm run build && devvit upload && devvit publish", "dev": "concurrently -k -p \"[{name}]\" -n \"CLIENT,SERVER,DEVVIT\" -c \"blue,green,magenta\" \"npm run dev:client\" \"npm run dev:server\" \"npm run dev:devvit\"", "dev:client": "cd src/client && vite build --watch", - "dev:devvit": "devvit playtest", + "dev:devvit": "dotenv -e .env -- node tools/devvit-playtest.mjs", "dev:server": "cd src/server && vite build --watch", "format": "prettier-package-json --write ./package.json && prettier --write .", "postinstall": "npm run build", From 89c68c6c38b4edaddab8beccb7028304b4fb4ff3 Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Wed, 6 May 2026 21:05:01 -0400 Subject: [PATCH 4/9] update solved page text & button with login prompt --- .../drawing-post/_components/ResultsView.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/client/entries/drawing-post/_components/ResultsView.tsx b/src/client/entries/drawing-post/_components/ResultsView.tsx index fa934fa..90a6b9d 100644 --- a/src/client/entries/drawing-post/_components/ResultsView.tsx +++ b/src/client/entries/drawing-post/_components/ResultsView.tsx @@ -13,6 +13,7 @@ import type { PostGuesses } from '@shared/schema/pixelary'; import { useTelemetry } from '@client/hooks/useTelemetry'; import { requestExpandedMode, + showLoginPrompt, addWebViewModeListener, removeWebViewModeListener, navigateTo, @@ -41,6 +42,7 @@ export function ResultsView({ postId, }: ResultsViewProps) { const [isLightboxOpen, setIsLightboxOpen] = useState(false); + const isLoggedIn = Boolean(context.userId); const { success } = useToastHelpers(); const { track } = useTelemetry(); const utils = trpc.useUtils(); @@ -198,26 +200,34 @@ export function ResultsView({ })} {/* Secondary CTA */} - + {isLoggedIn ? ( + + ) : ( + log in to save your rewards + )} {/* Primary CTA */} {/* Lightbox */} Date: Thu, 7 May 2026 08:10:20 -0400 Subject: [PATCH 5/9] copy loid guesses/solves to userId on log in --- .../entries/drawing-post/DrawingPost.tsx | 39 +++++--- src/server/core/redis.ts | 2 + src/server/services/drawing.test.ts | 89 ++++++++++++++++++- src/server/services/posts/drawing.ts | 83 +++++++++++++++++ src/server/services/progression.test.ts | 39 ++++++++ src/server/services/progression.ts | 32 +++++++ src/server/trpc/router.test.ts | 44 +++++++++ src/server/trpc/router.ts | 31 ++++++- 8 files changed, 346 insertions(+), 13 deletions(-) diff --git a/src/client/entries/drawing-post/DrawingPost.tsx b/src/client/entries/drawing-post/DrawingPost.tsx index 32b5c14..2b6a835 100644 --- a/src/client/entries/drawing-post/DrawingPost.tsx +++ b/src/client/entries/drawing-post/DrawingPost.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { GuessView } from './_components/GuessView'; import { ResultsView } from './_components/ResultsView'; @@ -47,13 +47,31 @@ function getAnonymousPlayerId( } } +function getStoredAnonymousPlayerId(): string | undefined { + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? undefined; + if (contextLoid) { + return contextLoid; + } + + try { + const key = 'pixelary:anonymous-player-id'; + const existing = window.localStorage.getItem(key); + return existing ?? undefined; + } catch { + return undefined; + } +} + export function DrawingPost() { const postData = getPostData(); const currentPostId = context.postId; - const loid = useMemo( - () => getAnonymousPlayerId(context.userId), - [context.userId] - ); + const loid = getAnonymousPlayerId(context.userId); + const migrationLoid = context.userId ? getStoredAnonymousPlayerId() : undefined; + const profileInput = { + postId: currentPostId, + ...(migrationLoid ? { loid: migrationLoid } : {}), + }; const { error: showErrorToast, success } = useToastHelpers(); // If postData is missing, try to trigger migration via API @@ -98,10 +116,9 @@ export function DrawingPost() { const [earnedPoints, setEarnedPoints] = useState(null); const [showConfetti, setShowConfetti] = useState(false); const lastShownPointsRef = useRef(null); - const { data: userProfile } = trpc.app.user.getProfile.useQuery( - { postId: currentPostId }, - { enabled: true } - ); + const { data: userProfile } = trpc.app.user.getProfile.useQuery(profileInput, { + enabled: true, + }); const { data: anonymousDrawingStatus } = trpc.app.guess.getStatus.useQuery( { postId: currentPostId, @@ -122,7 +139,7 @@ export function DrawingPost() { // Invalidate user profile to update score void queryClient.invalidateQueries({ - queryKey: ['pixelary', 'user', 'profile', { postId: variables.postId }], + queryKey: ['pixelary', 'user', 'profile', profileInput], }); // Invalidate leaderboard @@ -142,7 +159,7 @@ export function DrawingPost() { onSuccess: (_: unknown, variables: { postId: string }) => { // Invalidate user profile to update skipped status void queryClient.invalidateQueries({ - queryKey: ['pixelary', 'user', 'profile', { postId: variables.postId }], + queryKey: ['pixelary', 'user', 'profile', profileInput], }); // Invalidate post data to update skip count diff --git a/src/server/core/redis.ts b/src/server/core/redis.ts index 8586184..8f09312 100644 --- a/src/server/core/redis.ts +++ b/src/server/core/redis.ts @@ -112,6 +112,8 @@ export const REDIS_KEYS = { // Migration migrationLock: (postId: T3) => `migration:drawing:${postId}`, migrationMarker: (postId: T3) => `migrated:drawing:${postId}`, + guestProgressMigrationMarker: (postId: T3, guestId: string, userId: T2) => + `migration:guest_progress:${postId}:${guestId}:${userId}`, }; const MODERATOR_STATUS_TTL = 10 * 24 * 60 * 60; // 10 days. diff --git a/src/server/services/drawing.test.ts b/src/server/services/drawing.test.ts index daa6535..645cfc2 100644 --- a/src/server/services/drawing.test.ts +++ b/src/server/services/drawing.test.ts @@ -9,6 +9,7 @@ vi.mock('@devvit/web/server', () => ({ hSet: vi.fn(), zAdd: vi.fn(), zIncrBy: vi.fn(), + zRem: vi.fn(), zCard: vi.fn(), zRange: vi.fn(), exists: vi.fn(), @@ -55,12 +56,22 @@ vi.mock('./redis', () => ({ commentUpdateLock: (postId: string) => `comment_update_lock:${postId}`, scores: () => 'scores', rateGuess: (userId: string) => `rate:guess:${userId}`, + guestProgressMigrationMarker: ( + postId: string, + guestId: string, + userId: string + ) => `migration:guest_progress:${postId}:${guestId}:${userId}`, }, isRateLimited: vi.fn().mockResolvedValue(false), acquireLock: vi.fn().mockResolvedValue(true), })); -import { submitGuess, getGuesses, isAuthorFirstView } from './posts/drawing'; +import { + submitGuess, + getGuesses, + isAuthorFirstView, + migratePlayerProgressForPost, +} from './posts/drawing'; import { REDIS_KEYS } from '../core/redis'; import { redis } from '@devvit/web/server'; @@ -167,4 +178,80 @@ describe('Drawing Service', () => { ); }); }); + + describe('migratePlayerProgressForPost', () => { + it('copies attempts and solve status to logged-in user', async () => { + vi.mocked(redis.zScore) + .mockResolvedValueOnce(3) // from attempts + .mockResolvedValueOnce(undefined) // to attempts + .mockResolvedValueOnce(1715139959000) // from solved + .mockResolvedValueOnce(undefined) // to solved + .mockResolvedValueOnce(undefined) // from skipped + .mockResolvedValueOnce(undefined); // to skipped + vi.mocked(redis.set).mockResolvedValue('OK'); + + const migrated = await migratePlayerProgressForPost( + 't3_test123', + 'loid_test_123', + 't2_testuser' + ); + + expect(migrated).toBe(true); + expect(redis.zAdd).toHaveBeenCalledWith( + REDIS_KEYS.drawingAttempts('t3_test123'), + { + member: 't2_testuser', + score: 3, + } + ); + expect(redis.zAdd).toHaveBeenCalledWith( + REDIS_KEYS.drawingSolves('t3_test123'), + { + member: 't2_testuser', + score: 1715139959000, + } + ); + expect(redis.zRem).not.toHaveBeenCalled(); + }); + + it('returns false when no anonymous progress exists', async () => { + vi.mocked(redis.zScore) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + const migrated = await migratePlayerProgressForPost( + 't3_test123', + 'loid_test_123', + 't2_testuser' + ); + + expect(migrated).toBe(false); + expect(redis.zAdd).not.toHaveBeenCalled(); + expect(redis.zRem).not.toHaveBeenCalled(); + }); + + it('returns false if migration marker already exists', async () => { + vi.mocked(redis.zScore) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + vi.mocked(redis.set).mockResolvedValue(undefined); + + const migrated = await migratePlayerProgressForPost( + 't3_test123', + 'loid_test_123', + 't2_testuser' + ); + + expect(migrated).toBe(false); + expect(redis.zAdd).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/server/services/posts/drawing.ts b/src/server/services/posts/drawing.ts index 5f65afc..94eb988 100644 --- a/src/server/services/posts/drawing.ts +++ b/src/server/services/posts/drawing.ts @@ -688,6 +688,89 @@ export async function getUserDrawingStatus( return { solved: solved != null, skipped: skipped != null, guessCount }; } +export async function migratePlayerProgressForPost( + postId: T3, + fromPlayerId: string, + toPlayerId: T2 +): Promise { + if (!fromPlayerId || fromPlayerId === toPlayerId) { + return false; + } + + const [ + fromAttemptCount, + toAttemptCount, + fromSolvedAt, + toSolvedAt, + fromSkippedAt, + toSkippedAt, + ] = await Promise.all([ + redis.zScore(REDIS_KEYS.drawingAttempts(postId), fromPlayerId), + redis.zScore(REDIS_KEYS.drawingAttempts(postId), toPlayerId), + redis.zScore(REDIS_KEYS.drawingSolves(postId), fromPlayerId), + redis.zScore(REDIS_KEYS.drawingSolves(postId), toPlayerId), + redis.zScore(REDIS_KEYS.drawingSkips(postId), fromPlayerId), + redis.zScore(REDIS_KEYS.drawingSkips(postId), toPlayerId), + ]); + + if ( + fromAttemptCount == null && + fromSolvedAt == null && + fromSkippedAt == null + ) { + return false; + } + + const markerKey = REDIS_KEYS.guestProgressMigrationMarker( + postId, + fromPlayerId, + toPlayerId + ); + const marked = await redis.set(markerKey, '1', { nx: true }); + if (!marked) { + return false; + } + + const operations: Array> = []; + + if (fromAttemptCount != null) { + const mergedAttempts = + Number(toAttemptCount ?? 0) + Number(fromAttemptCount); + operations.push( + redis.zAdd(REDIS_KEYS.drawingAttempts(postId), { + member: toPlayerId, + score: mergedAttempts, + }) + ); + } + + if (fromSolvedAt != null) { + if (toSolvedAt == null) { + operations.push( + redis.zAdd(REDIS_KEYS.drawingSolves(postId), { + member: toPlayerId, + score: fromSolvedAt, + }) + ); + } + } + + if (fromSkippedAt != null) { + const shouldCarrySkip = fromSolvedAt == null && toSolvedAt == null; + if (shouldCarrySkip && toSkippedAt == null) { + operations.push( + redis.zAdd(REDIS_KEYS.drawingSkips(postId), { + member: toPlayerId, + score: fromSkippedAt, + }) + ); + } + } + + await Promise.all(operations); + return true; +} + export async function isAuthorFirstView(postId: T3): Promise { const key = REDIS_KEYS.authorViews(postId); const views = await redis.incrBy(key, 1); diff --git a/src/server/services/progression.test.ts b/src/server/services/progression.test.ts index a40d498..5d31d31 100644 --- a/src/server/services/progression.test.ts +++ b/src/server/services/progression.test.ts @@ -7,6 +7,7 @@ import { getLevelByScore, getUserLevel, getRank, + mergeGuestScoreIntoUser, } from './progression'; import { redis, scheduler, cache } from '@devvit/web/server'; import { LEVELS } from '@shared/constants'; @@ -16,8 +17,11 @@ import { REDIS_KEYS } from '../core/redis'; vi.mock('../core/redis', () => ({ REDIS_KEYS: { scores: () => 'scores', + scoresGuest: () => 'scores:guest', userLevelUpClaim: (userId: string) => `user:${userId}:levelup`, }, + acquireLock: vi.fn(async () => true), + releaseLock: vi.fn(async () => {}), })); vi.mock('../core/user', () => ({ @@ -259,4 +263,39 @@ describe('Leaderboard Service', () => { expect(rank).toBe(-1); }); }); + + describe('mergeGuestScoreIntoUser', () => { + it('moves guest score to logged-in user score', async () => { + vi.mocked(redis.zScore) + .mockResolvedValueOnce(25) // guest score + .mockResolvedValueOnce(100); // existing user score (via getScore) + vi.mocked(redis.zAdd).mockResolvedValue(undefined); + vi.mocked(redis.zRem).mockResolvedValue(1); + + const moved = await mergeGuestScoreIntoUser( + 'loid_test_123', + 't2_testuser' + ); + + expect(moved).toBe(25); + expect(redis.zAdd).toHaveBeenCalledWith('scores', { + member: 't2_testuser', + score: 125, + }); + expect(redis.zRem).toHaveBeenCalledWith('scores:guest', 'loid_test_123'); + }); + + it('no-ops when guest score does not exist', async () => { + vi.mocked(redis.zScore).mockResolvedValue(undefined); + + const moved = await mergeGuestScoreIntoUser( + 'loid_test_123', + 't2_testuser' + ); + + expect(moved).toBe(0); + expect(redis.zAdd).not.toHaveBeenCalled(); + expect(redis.zRem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/server/services/progression.ts b/src/server/services/progression.ts index ba8bd56..be021d1 100644 --- a/src/server/services/progression.ts +++ b/src/server/services/progression.ts @@ -1,5 +1,6 @@ import { redis, scheduler, context, realtime } from '@devvit/web/server'; import { REDIS_KEYS } from '../core/redis'; +import { acquireLock, releaseLock } from '../core/redis'; import { getUsername } from '../core/user'; import { getLevelByScore as getLevelByScoreUtil } from '@shared/utils/progression'; import type { T2 } from '@devvit/shared-types/tid.js'; @@ -240,6 +241,37 @@ export async function incrementScore( return score; } +export async function mergeGuestScoreIntoUser( + guestId: string, + userId: T2 +): Promise { + if (!guestId || isT2(guestId)) { + return 0; + } + + const lockKey = `migration:guest-score:${userId}:${guestId}`; + const hasLock = await acquireLock(lockKey, 5000); + if (!hasLock) { + return 0; + } + + try { + const guestScore = await redis.zScore(REDIS_KEYS.scoresGuest(), guestId); + const guestScoreValue = + typeof guestScore === 'number' ? guestScore : Number(guestScore ?? 0); + if (guestScoreValue <= 0) { + return 0; + } + + const existingScore = await getScore(userId); + await setScore(userId, existingScore + guestScoreValue); + await redis.zRem(REDIS_KEYS.scoresGuest(), guestId); + return guestScoreValue; + } finally { + await releaseLock(lockKey); + } +} + /** * Get the level by score (delegates to shared utility) * @param score - The score diff --git a/src/server/trpc/router.test.ts b/src/server/trpc/router.test.ts index f540776..fcdd674 100644 --- a/src/server/trpc/router.test.ts +++ b/src/server/trpc/router.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { appRouter } from './router'; import { redis } from '@devvit/web/server'; import * as drawingService from '../services/posts/drawing'; +import * as progressionService from '../services/progression'; import { createMockDictionary, createMockUserProfile, @@ -61,6 +62,7 @@ vi.mock('../services/posts/drawing', () => ({ guessCount: 1, })), getUserDrawings: vi.fn(async () => []), + migratePlayerProgressForPost: vi.fn(async () => true), })); vi.mock('../services/progression', () => ({ @@ -71,6 +73,7 @@ vi.mock('../services/progression', () => ({ getRank: vi.fn(async () => 1), getUserLevel: vi.fn(async () => ({ rank: 1, name: 'Newcomer' })), getLevelProgressPercentage: vi.fn(() => 50), + mergeGuestScoreIntoUser: vi.fn(async () => 0), })); vi.mock('@devvit/web/server', () => { @@ -229,6 +232,47 @@ describe('appRouter', () => { expect(profile).toBeTruthy(); }); + it('app.user.getProfile migrates anonymous progress on login', async () => { + const withLoidCaller = appRouter.createCaller({ + ...ctx, + userId: 't2_user123', + loid: 'loid_test_123', + } as unknown as Parameters[0]); + + const profile = await withLoidCaller.app.user.getProfile({ + postId: 't3_test123', + }); + + expect(profile).toBeTruthy(); + expect( + vi.mocked(progressionService.mergeGuestScoreIntoUser) + ).toHaveBeenCalledWith('loid_test_123', 't2_user123'); + expect( + vi.mocked(drawingService.migratePlayerProgressForPost) + ).toHaveBeenCalledWith('t3_test123', 'loid_test_123', 't2_user123'); + }); + + it('app.user.getProfile uses input loid when context loid is missing', async () => { + const noContextLoidCaller = appRouter.createCaller({ + ...ctx, + userId: 't2_user123', + loid: null, + } as unknown as Parameters[0]); + + const profile = await noContextLoidCaller.app.user.getProfile({ + postId: 't3_test123', + loid: 'loid_from_input', + }); + + expect(profile).toBeTruthy(); + expect( + vi.mocked(progressionService.mergeGuestScoreIntoUser) + ).toHaveBeenCalledWith('loid_from_input', 't2_user123'); + expect( + vi.mocked(drawingService.migratePlayerProgressForPost) + ).toHaveBeenCalledWith('t3_test123', 'loid_from_input', 't2_user123'); + }); + it('app.user.getRank returns user rank', async () => { const rank = await caller.app.user.getRank(); expect(rank).toBeTruthy(); diff --git a/src/server/trpc/router.ts b/src/server/trpc/router.ts index 95913d6..054eb4d 100644 --- a/src/server/trpc/router.ts +++ b/src/server/trpc/router.ts @@ -28,6 +28,7 @@ import { getUserDrawingsWithData, getUserDrawingStatus, isAuthorFirstView, + migratePlayerProgressForPost, } from '@server/services/posts/drawing'; import { getLeaderboard, @@ -37,6 +38,7 @@ import { getLevelProgressPercentage, getUnclaimedLevelUp, claimLevelUp, + mergeGuestScoreIntoUser, } from '@server/services/progression'; import { isAdmin, isModerator } from '@server/core/redis'; import { @@ -548,9 +550,29 @@ export const appRouter = t.router({ }), }), getProfile: t.procedure - .input(z.object({ postId: z.string() }).optional()) + .input( + z + .object({ + postId: z.string().optional(), + loid: z.string().optional(), + }) + .optional() + ) .query(async ({ ctx, input }) => { if (!ctx.userId) return null; + const guestId = input?.loid ?? ctx.loid; + + if (guestId) { + try { + await mergeGuestScoreIntoUser(guestId, ctx.userId); + } catch (error) { + console.warn('Failed to merge guest score into user account', { + userId: ctx.userId, + loid: guestId, + error: error instanceof Error ? error.message : String(error), + }); + } + } const score = await getScore(ctx.userId); const [rank, level] = await Promise.all([ @@ -563,6 +585,13 @@ export const appRouter = t.router({ if (input?.postId) { try { assertT3(input.postId); + if (guestId) { + await migratePlayerProgressForPost( + input.postId, + guestId, + ctx.userId + ); + } drawingStatus = await getUserDrawingStatus( input.postId, ctx.userId From 70983bd0756aa1f33c5831fd51e7454b1040a122 Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Thu, 7 May 2026 09:42:03 -0400 Subject: [PATCH 6/9] don't duplicate points transfer between userId/loid, but if same loid accrues more points, those should be attributed to userId when logged in --- src/server/core/redis.ts | 2 + src/server/services/progression.test.ts | 53 +++++++++++++++++++++++-- src/server/services/progression.ts | 18 +++++++-- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/server/core/redis.ts b/src/server/core/redis.ts index 8f09312..4089f07 100644 --- a/src/server/core/redis.ts +++ b/src/server/core/redis.ts @@ -114,6 +114,8 @@ export const REDIS_KEYS = { migrationMarker: (postId: T3) => `migrated:drawing:${postId}`, guestProgressMigrationMarker: (postId: T3, guestId: string, userId: T2) => `migration:guest_progress:${postId}:${guestId}:${userId}`, + guestScoreMigrationMarker: (guestId: string, userId: T2) => + `migration:guest_score:${guestId}:${userId}`, }; const MODERATOR_STATUS_TTL = 10 * 24 * 60 * 60; // 10 days. diff --git a/src/server/services/progression.test.ts b/src/server/services/progression.test.ts index 5d31d31..a5ab384 100644 --- a/src/server/services/progression.test.ts +++ b/src/server/services/progression.test.ts @@ -19,6 +19,8 @@ vi.mock('../core/redis', () => ({ scores: () => 'scores', scoresGuest: () => 'scores:guest', userLevelUpClaim: (userId: string) => `user:${userId}:levelup`, + guestScoreMigrationMarker: (guestId: string, userId: string) => + `migration:guest_score:${guestId}:${userId}`, }, acquireLock: vi.fn(async () => true), releaseLock: vi.fn(async () => {}), @@ -265,12 +267,13 @@ describe('Leaderboard Service', () => { }); describe('mergeGuestScoreIntoUser', () => { - it('moves guest score to logged-in user score', async () => { + it('copies full guest score when never migrated before', async () => { + vi.mocked(redis.get).mockResolvedValue(undefined); vi.mocked(redis.zScore) .mockResolvedValueOnce(25) // guest score .mockResolvedValueOnce(100); // existing user score (via getScore) vi.mocked(redis.zAdd).mockResolvedValue(undefined); - vi.mocked(redis.zRem).mockResolvedValue(1); + vi.mocked(redis.set).mockResolvedValue('OK'); const moved = await mergeGuestScoreIntoUser( 'loid_test_123', @@ -282,10 +285,38 @@ describe('Leaderboard Service', () => { member: 't2_testuser', score: 125, }); - expect(redis.zRem).toHaveBeenCalledWith('scores:guest', 'loid_test_123'); + expect(redis.set).toHaveBeenCalledWith( + 'migration:guest_score:loid_test_123:t2_testuser', + '25' + ); + }); + + it('copies only new delta after prior migration', async () => { + vi.mocked(redis.get).mockResolvedValue('25'); + vi.mocked(redis.zScore) + .mockResolvedValueOnce(40) // guest score now + .mockResolvedValueOnce(100); // existing user score + vi.mocked(redis.zAdd).mockResolvedValue(undefined); + vi.mocked(redis.set).mockResolvedValue('OK'); + + const moved = await mergeGuestScoreIntoUser( + 'loid_test_123', + 't2_testuser' + ); + + expect(moved).toBe(15); + expect(redis.zAdd).toHaveBeenCalledWith('scores', { + member: 't2_testuser', + score: 115, + }); + expect(redis.set).toHaveBeenCalledWith( + 'migration:guest_score:loid_test_123:t2_testuser', + '40' + ); }); it('no-ops when guest score does not exist', async () => { + vi.mocked(redis.get).mockResolvedValue(undefined); vi.mocked(redis.zScore).mockResolvedValue(undefined); const moved = await mergeGuestScoreIntoUser( @@ -295,7 +326,21 @@ describe('Leaderboard Service', () => { expect(moved).toBe(0); expect(redis.zAdd).not.toHaveBeenCalled(); - expect(redis.zRem).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it('no-ops when no new guest progress exists', async () => { + vi.mocked(redis.get).mockResolvedValue('25'); + vi.mocked(redis.zScore).mockResolvedValue(25); + + const moved = await mergeGuestScoreIntoUser( + 'loid_test_123', + 't2_testuser' + ); + + expect(moved).toBe(0); + expect(redis.zAdd).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); }); }); }); diff --git a/src/server/services/progression.ts b/src/server/services/progression.ts index be021d1..10ce65e 100644 --- a/src/server/services/progression.ts +++ b/src/server/services/progression.ts @@ -256,17 +256,27 @@ export async function mergeGuestScoreIntoUser( } try { + const markerKey = REDIS_KEYS.guestScoreMigrationMarker(guestId, userId); + const migratedRaw = await redis.get(markerKey); + const migratedSoFar = + migratedRaw == null ? 0 : Number.parseFloat(String(migratedRaw)); + const guestScore = await redis.zScore(REDIS_KEYS.scoresGuest(), guestId); const guestScoreValue = typeof guestScore === 'number' ? guestScore : Number(guestScore ?? 0); - if (guestScoreValue <= 0) { + const safeMigratedSoFar = Number.isFinite(migratedSoFar) + ? Math.max(0, migratedSoFar) + : 0; + const delta = guestScoreValue - safeMigratedSoFar; + + if (delta <= 0) { return 0; } const existingScore = await getScore(userId); - await setScore(userId, existingScore + guestScoreValue); - await redis.zRem(REDIS_KEYS.scoresGuest(), guestId); - return guestScoreValue; + await setScore(userId, existingScore + delta); + await redis.set(markerKey, guestScoreValue.toString()); + return delta; } finally { await releaseLock(lockKey); } From f1586f42ffb6615e5a03c08a579a5d040877528b Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Fri, 8 May 2026 12:12:20 -0400 Subject: [PATCH 7/9] don't generate anon id if context.loid is missing --- .../entries/drawing-post/DrawingPost.tsx | 73 ++++--------------- src/server/trpc/router.test.ts | 30 ++++++-- src/server/trpc/router.ts | 3 +- 3 files changed, 37 insertions(+), 69 deletions(-) diff --git a/src/client/entries/drawing-post/DrawingPost.tsx b/src/client/entries/drawing-post/DrawingPost.tsx index 2b6a835..e26d229 100644 --- a/src/client/entries/drawing-post/DrawingPost.tsx +++ b/src/client/entries/drawing-post/DrawingPost.tsx @@ -16,62 +16,12 @@ import type { DrawingPostData } from '@src/shared/schema'; type DrawingState = 'unsolved' | 'guessing' | 'solved' | 'skipped' | 'author'; -function getAnonymousPlayerId( - userId: string | null | undefined -): string | undefined { - if (userId) { - return undefined; - } - - const contextLoid = - (context as typeof context & { loid?: string | null }).loid ?? undefined; - if (contextLoid) { - return contextLoid; - } - - try { - const key = 'pixelary:anonymous-player-id'; - const existing = window.localStorage.getItem(key); - if (existing) { - return existing; - } - - const generated = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? `anon_${crypto.randomUUID()}` - : `anon_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; - window.localStorage.setItem(key, generated); - return generated; - } catch { - return undefined; - } -} - -function getStoredAnonymousPlayerId(): string | undefined { - const contextLoid = - (context as typeof context & { loid?: string | null }).loid ?? undefined; - if (contextLoid) { - return contextLoid; - } - - try { - const key = 'pixelary:anonymous-player-id'; - const existing = window.localStorage.getItem(key); - return existing ?? undefined; - } catch { - return undefined; - } -} - export function DrawingPost() { const postData = getPostData(); const currentPostId = context.postId; - const loid = getAnonymousPlayerId(context.userId); - const migrationLoid = context.userId ? getStoredAnonymousPlayerId() : undefined; - const profileInput = { - postId: currentPostId, - ...(migrationLoid ? { loid: migrationLoid } : {}), - }; + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? undefined; + const profileInput = { postId: currentPostId }; const { error: showErrorToast, success } = useToastHelpers(); // If postData is missing, try to trigger migration via API @@ -116,16 +66,19 @@ export function DrawingPost() { const [earnedPoints, setEarnedPoints] = useState(null); const [showConfetti, setShowConfetti] = useState(false); const lastShownPointsRef = useRef(null); - const { data: userProfile } = trpc.app.user.getProfile.useQuery(profileInput, { - enabled: true, - }); + const { data: userProfile } = trpc.app.user.getProfile.useQuery( + profileInput, + { + enabled: true, + } + ); const { data: anonymousDrawingStatus } = trpc.app.guess.getStatus.useQuery( { postId: currentPostId, - ...(loid ? { loid } : {}), + ...(contextLoid ? { loid: contextLoid } : {}), }, { - enabled: !context.userId && !!effectivePostData && !!loid, + enabled: !context.userId && !!effectivePostData, refetchOnWindowFocus: false, } ); @@ -321,7 +274,7 @@ export function DrawingPost() { const result = await submitGuess.mutateAsync({ postId: currentPostId, guess, - ...(!context.userId && loid ? { loid } : {}), + ...(!context.userId && contextLoid ? { loid: contextLoid } : {}), }); // Only change state after server confirms @@ -346,7 +299,7 @@ export function DrawingPost() { try { await skipPost.mutateAsync({ postId: currentPostId, - ...(!context.userId && loid ? { loid } : {}), + ...(!context.userId && contextLoid ? { loid: contextLoid } : {}), }); setCurrentState('skipped'); } catch (err) { diff --git a/src/server/trpc/router.test.ts b/src/server/trpc/router.test.ts index fcdd674..f303408 100644 --- a/src/server/trpc/router.test.ts +++ b/src/server/trpc/router.test.ts @@ -252,7 +252,7 @@ describe('appRouter', () => { ).toHaveBeenCalledWith('t3_test123', 'loid_test_123', 't2_user123'); }); - it('app.user.getProfile uses input loid when context loid is missing', async () => { + it('app.user.getProfile does not migrate when context loid is missing', async () => { const noContextLoidCaller = appRouter.createCaller({ ...ctx, userId: 't2_user123', @@ -261,16 +261,15 @@ describe('appRouter', () => { const profile = await noContextLoidCaller.app.user.getProfile({ postId: 't3_test123', - loid: 'loid_from_input', }); expect(profile).toBeTruthy(); expect( vi.mocked(progressionService.mergeGuestScoreIntoUser) - ).toHaveBeenCalledWith('loid_from_input', 't2_user123'); + ).not.toHaveBeenCalled(); expect( vi.mocked(drawingService.migratePlayerProgressForPost) - ).toHaveBeenCalledWith('t3_test123', 'loid_from_input', 't2_user123'); + ).not.toHaveBeenCalled(); }); it('app.user.getRank returns user rank', async () => { @@ -313,7 +312,6 @@ describe('appRouter', () => { const result = await anonymousCaller.app.guess.submit({ ...createMockGuessSubmitInput(), - loid: 'loid_test_123', }); expect(result).toBeTruthy(); @@ -333,7 +331,6 @@ describe('appRouter', () => { const result = await anonymousCaller.app.guess.skip({ postId: 't3_test123', - loid: 'loid_test_123', }); expect(result).toEqual({ success: true }); @@ -357,7 +354,6 @@ describe('appRouter', () => { const status = await anonymousCaller.app.guess.getStatus({ postId: 't3_test123', - loid: 'loid_test_123', }); expect(status).toEqual({ solved: true, skipped: false, guessCount: 1 }); @@ -365,6 +361,26 @@ describe('appRouter', () => { vi.mocked(drawingService.getUserDrawingStatus) ).toHaveBeenCalledWith('t3_test123', 'loid_test_123'); }); + + it('app.guess.submit uses input loid when context loid is missing', async () => { + const missingContextLoidCaller = appRouter.createCaller({ + ...ctx, + userId: null, + loid: null, + } as unknown as Parameters[0]); + + const result = await missingContextLoidCaller.app.guess.submit({ + ...createMockGuessSubmitInput(), + loid: 'loid_from_context_client', + }); + + expect(result).toBeTruthy(); + expect(vi.mocked(drawingService.submitGuess)).toHaveBeenCalledWith( + expect.objectContaining({ + playerId: 'loid_from_context_client', + }) + ); + }); it('app.slate.trackAction handles slate_posted with explicit postId', async () => { await expect( caller.app.slate.trackAction({ diff --git a/src/server/trpc/router.ts b/src/server/trpc/router.ts index 054eb4d..19ae118 100644 --- a/src/server/trpc/router.ts +++ b/src/server/trpc/router.ts @@ -554,13 +554,12 @@ export const appRouter = t.router({ z .object({ postId: z.string().optional(), - loid: z.string().optional(), }) .optional() ) .query(async ({ ctx, input }) => { if (!ctx.userId) return null; - const guestId = input?.loid ?? ctx.loid; + const guestId = ctx.loid; if (guestId) { try { From f992d3637a6ffbe3d0cfc715c269d333403bddad Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Fri, 8 May 2026 13:36:56 -0400 Subject: [PATCH 8/9] add fallback client.loid in case server.loid isn't working --- .../entries/drawing-post/DrawingPost.tsx | 5 ++++- .../entries/editor/_context/EditorContext.tsx | 11 ++++++++--- src/client/entries/pinned-post/PinnedPost.tsx | 13 +++++++++---- .../pinned-post/_components/LevelDetails.tsx | 12 +++++++++--- .../entries/pinned-post/_components/Menu.tsx | 17 ++++++++++++----- .../pinned-post/_components/MyRewards.tsx | 6 +++++- src/server/trpc/router.ts | 18 +++++++++++++++++- 7 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/client/entries/drawing-post/DrawingPost.tsx b/src/client/entries/drawing-post/DrawingPost.tsx index e26d229..5d3407b 100644 --- a/src/client/entries/drawing-post/DrawingPost.tsx +++ b/src/client/entries/drawing-post/DrawingPost.tsx @@ -21,7 +21,10 @@ export function DrawingPost() { const currentPostId = context.postId; const contextLoid = (context as typeof context & { loid?: string | null }).loid ?? undefined; - const profileInput = { postId: currentPostId }; + const profileInput = { + postId: currentPostId, + ...(contextLoid ? { loid: contextLoid } : {}), + }; const { error: showErrorToast, success } = useToastHelpers(); // If postData is missing, try to trigger migration via API diff --git a/src/client/entries/editor/_context/EditorContext.tsx b/src/client/entries/editor/_context/EditorContext.tsx index 7b56427..700d0ed 100644 --- a/src/client/entries/editor/_context/EditorContext.tsx +++ b/src/client/entries/editor/_context/EditorContext.tsx @@ -70,6 +70,8 @@ export function EditorContextProvider(props: ProviderProps) { tournamentPostId, tournamentWord, } = props; + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? undefined; // flow const flow = useEditorFlow({ @@ -84,9 +86,12 @@ export function EditorContextProvider(props: ProviderProps) { }); // queries - const { data: userProfile } = trpc.app.user.getProfile.useQuery(undefined, { - staleTime: 30000, - }); + const { data: userProfile } = trpc.app.user.getProfile.useQuery( + contextLoid ? { loid: contextLoid } : undefined, + { + staleTime: 30000, + } + ); const { data: effectiveBonuses } = trpc.app.rewards.getEffectiveBonuses.useQuery(undefined, { enabled: !!context.userId, diff --git a/src/client/entries/pinned-post/PinnedPost.tsx b/src/client/entries/pinned-post/PinnedPost.tsx index 98c24a4..75b1657 100644 --- a/src/client/entries/pinned-post/PinnedPost.tsx +++ b/src/client/entries/pinned-post/PinnedPost.tsx @@ -18,6 +18,8 @@ export function PinnedPost() { const [page, setPage] = useState('menu'); const utils = trpc.useUtils(); const isLoggedIn = Boolean(context.userId); + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? undefined; // Prefetch drawings optimistically for maximum performance trpc.app.user.getMyArtPage.useQuery( @@ -39,10 +41,13 @@ export function PinnedPost() { ); // Warm profile and bonuses so downstream views benefit from cache - trpc.app.user.getProfile.useQuery(undefined, { - staleTime: 60000, - refetchOnWindowFocus: false, - }); + trpc.app.user.getProfile.useQuery( + contextLoid ? { loid: contextLoid } : undefined, + { + staleTime: 60000, + refetchOnWindowFocus: false, + } + ); trpc.app.rewards.getEffectiveBonuses.useQuery(undefined, { enabled: isLoggedIn, staleTime: 10000, diff --git a/src/client/entries/pinned-post/_components/LevelDetails.tsx b/src/client/entries/pinned-post/_components/LevelDetails.tsx index f97b852..b499cff 100644 --- a/src/client/entries/pinned-post/_components/LevelDetails.tsx +++ b/src/client/entries/pinned-post/_components/LevelDetails.tsx @@ -6,6 +6,7 @@ import { IconButton } from '@components/IconButton'; import { trpc } from '@client/trpc/client'; import { useTelemetry } from '@client/hooks/useTelemetry'; import { getRewardsByLevel, getRewardLabel } from '@shared/rewards'; +import { context } from '@devvit/web/client'; type LevelDetailsProps = { onClose: () => void; @@ -13,6 +14,8 @@ type LevelDetailsProps = { export function LevelDetails({ onClose }: LevelDetailsProps) { const { track } = useTelemetry(); + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? undefined; // Track level details view on mount useEffect(() => { @@ -20,9 +23,12 @@ export function LevelDetails({ onClose }: LevelDetailsProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Get user profile to show their actual progress - const { data: userProfile } = trpc.app.user.getProfile.useQuery(undefined, { - enabled: true, - }); + const { data: userProfile } = trpc.app.user.getProfile.useQuery( + contextLoid ? { loid: contextLoid } : undefined, + { + enabled: true, + } + ); const [currentLevelRank, setCurrentLevelRank] = useState(1); const [displayProgress, setDisplayProgress] = useState(0); diff --git a/src/client/entries/pinned-post/_components/Menu.tsx b/src/client/entries/pinned-post/_components/Menu.tsx index 912c05a..f07373f 100644 --- a/src/client/entries/pinned-post/_components/Menu.tsx +++ b/src/client/entries/pinned-post/_components/Menu.tsx @@ -26,6 +26,8 @@ type MenuProps = { export function Menu(props: MenuProps) { const { onMyDrawings, onLeaderboard, onHowToPlay, onLevelClick } = props; const isLoggedIn = Boolean(context.userId); + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? null; // Telemetry const { track } = useTelemetry(); @@ -36,9 +38,12 @@ export function Menu(props: MenuProps) { }, []); // Grab data - const { data: userProfile } = trpc.app.user.getProfile.useQuery(undefined, { - enabled: true, - }); + const { data: userProfile } = trpc.app.user.getProfile.useQuery( + contextLoid ? { loid: contextLoid } : undefined, + { + enabled: true, + } + ); // Check if user is admin const { data: isUserAdmin } = trpc.app.user.isAdmin.useQuery(undefined, { @@ -47,11 +52,13 @@ export function Menu(props: MenuProps) { // Warm editor-related caches as soon as the menu is visible useEffect(() => { - void utils.app.user.getProfile.prefetch(); + void utils.app.user.getProfile.prefetch( + contextLoid ? { loid: contextLoid } : undefined + ); void utils.app.rewards.getEffectiveBonuses.prefetch(); void utils.app.user.colors.getRecent.prefetch(); void utils.app.dictionary.getCandidates.prefetch(); - }, [utils]); + }, [contextLoid, utils]); // Get progress percentage from user profile const progressPercentage = userProfile?.levelProgressPercentage ?? 0; diff --git a/src/client/entries/pinned-post/_components/MyRewards.tsx b/src/client/entries/pinned-post/_components/MyRewards.tsx index 5cc8f88..33a46c7 100644 --- a/src/client/entries/pinned-post/_components/MyRewards.tsx +++ b/src/client/entries/pinned-post/_components/MyRewards.tsx @@ -41,9 +41,13 @@ export function MyRewards({ onClose }: MyRewardsProps) { void track('view_my_rewards'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const contextLoid = + (context as typeof context & { loid?: string | null }).loid ?? undefined; // Get user profile data - const { data: userProfile } = trpc.app.user.getProfile.useQuery(); + const { data: userProfile } = trpc.app.user.getProfile.useQuery( + contextLoid ? { loid: contextLoid } : undefined + ); const userLevel = userProfile?.level ?? 1; const allRewards = getAllRewards(); diff --git a/src/server/trpc/router.ts b/src/server/trpc/router.ts index 19ae118..357fb2c 100644 --- a/src/server/trpc/router.ts +++ b/src/server/trpc/router.ts @@ -81,11 +81,26 @@ function getPlayerId(ctx: Context, loid?: string): string | null { return ctx.userId; } if (ctx.loid) { + if (loid && loid !== ctx.loid) { + console.warn('[LOID] Context/input mismatch; using context.loid', { + contextLoid: ctx.loid, + inputLoid: loid, + }); + } else { + console.log('[LOID] Using context.loid for anonymous player', { + contextLoid: ctx.loid, + hasInputLoid: Boolean(loid), + }); + } return ctx.loid; } if (loid) { + console.warn('[LOID] context.loid missing; falling back to input loid', { + inputLoid: loid, + }); return loid; } + console.warn('[LOID] No loid available for anonymous player'); return null; } @@ -554,12 +569,13 @@ export const appRouter = t.router({ z .object({ postId: z.string().optional(), + loid: z.string().optional(), }) .optional() ) .query(async ({ ctx, input }) => { if (!ctx.userId) return null; - const guestId = ctx.loid; + const guestId = ctx.loid ?? input?.loid; if (guestId) { try { From 6beb358f77d5a34b7dbcb50f341fbefbbef68e34 Mon Sep 17 00:00:00 2001 From: Jasmine Elkins Date: Fri, 8 May 2026 17:41:06 -0400 Subject: [PATCH 9/9] fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 002f412..6199511 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "deploy": "npm run build && devvit upload && devvit publish", "dev": "concurrently -k -p \"[{name}]\" -n \"CLIENT,SERVER,DEVVIT\" -c \"blue,green,magenta\" \"npm run dev:client\" \"npm run dev:server\" \"npm run dev:devvit\"", "dev:client": "cd src/client && vite build --watch", - "dev:devvit": "dotenv -e .env -- node tools/devvit-playtest.mjs", + "dev:devvit": "devvit playtest", "dev:server": "cd src/server && vite build --watch", "format": "prettier-package-json --write ./package.json && prettier --write .", "postinstall": "npm run build",