Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion devvit.json
Original file line number Diff line number Diff line change
Expand Up @@ -355,5 +355,6 @@
},
"dev": {
"subreddit": "HotAndColdDev"
}
},
"sourceIgnores": ["tools/english-wordnet-2024.xml"]
}
690 changes: 368 additions & 322 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "devvit playtest",
"dev": "dotenv -- node ./scripts/dev-app-playtesting.mjs",
"lint": "eslint ./src",
"test": "vitest run",
"lint:fix": "eslint --fix ./src",
Expand Down Expand Up @@ -43,19 +43,19 @@
"new:upload-words": "dotenv -- node ./tools/new-upload-words-to-db.ts"
},
"dependencies": {
"@devvit/analytics": "0.12.12-next-2026-02-03-22-55-04-032301a3c.0",
"@devvit/notifications": "0.12.12-next-2026-02-03-22-55-04-032301a3c.0",
"@devvit/shared-types": "0.12.12-next-2026-02-03-22-55-04-032301a3c.0",
"@devvit/start": "0.12.12-next-2026-02-03-22-55-04-032301a3c.0",
"@devvit/test": "0.12.12-next-2026-02-03-22-55-04-032301a3c.0",
"@devvit/web": "0.12.12-next-2026-02-03-22-55-04-032301a3c.0",
"@devvit/analytics": "0.12.23-next-2026-05-01-20-06-15-4d8baf0a4.0",
"@devvit/notifications": "0.12.23-next-2026-05-01-20-06-15-4d8baf0a4.0",
"@devvit/shared-types": "0.12.23-next-2026-05-01-20-06-15-4d8baf0a4.0",
"@devvit/start": "0.12.23-next-2026-05-01-20-06-15-4d8baf0a4.0",
"@devvit/test": "0.12.23-next-2026-05-01-20-06-15-4d8baf0a4.0",
"@devvit/web": "0.12.23-next-2026-05-01-20-06-15-4d8baf0a4.0",
"@google/genai": "1.16.0",
"@preact/signals": "^1.2.2",
"@trpc/client": "11.4.4",
"@trpc/server": "11.4.4",
"better-sqlite3": "12.2.0",
"clsx": "2.1.1",
"devvit": "0.12.12-next-2026-02-03-22-55-04-032301a3c.0",
"devvit": "0.12.23-next-2026-05-01-20-06-15-4d8baf0a4.0",
"express": "4.19.2",
"fast-csv": "5.0.5",
"fast-xml-parser": "5.2.5",
Expand Down
75 changes: 74 additions & 1 deletion src/client/classic/WinPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,41 @@ import { getBrowserIanaTimeZone } from '../../shared/timezones';
import { formatCompactNumber } from '../../shared/formatCompactNumber';

type LeaderboardEntry = { member: string; score: number };
type LocalWinningGuess = {
word: string;
similarity: number;
rank: number;
timestamp: number;
};

const prettyNumber = (num: number): string => num.toLocaleString('en-US');

const readLocalWinningGuess = (challengeNumber: number): LocalWinningGuess | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(`guess-history:${String(challengeNumber)}`);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
const winning = parsed
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
.find((item) => Number(item.similarity) === 1);
if (!winning) return null;
const word = String(winning.word ?? '').trim().toLowerCase();
if (!word) return null;
const rank = Number(winning.rank);
const timestamp = Number(winning.timestamp);
return {
word,
similarity: 1,
rank: Number.isFinite(rank) ? rank : -1,
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
};
} catch {
return null;
}
};

const StatCard = ({
title,
value,
Expand Down Expand Up @@ -321,6 +353,47 @@ export function WinPage() {
})();
}, [challengeNumber]);

useEffect(() => {
if (!context.userId) return;
if (!challengeUserInfo) return;
if (challengeUserInfo.solvedAtMs) return;

const localWinningGuess = readLocalWinningGuess(challengeNumber);
if (!localWinningGuess) return;

let cancelled = false;
void (async () => {
try {
await trpc.guess.submitBatch.mutate({
challengeNumber,
guesses: [
{
word: localWinningGuess.word,
similarity: localWinningGuess.similarity,
rank: localWinningGuess.rank,
atMs: localWinningGuess.timestamp,
},
],
});
} catch {
// ignore
}

try {
const refreshed = await trpc.game.get.query({ challengeNumber });
if (cancelled) return;
setChallengeInfo(refreshed.challengeInfo);
setChallengeUserInfo(refreshed.challengeUserInfo);
} catch {
// ignore
}
})();

return () => {
cancelled = true;
};
}, [challengeNumber, challengeUserInfo, challengeUserInfo?.solvedAtMs]);

if (!challengeUserInfo || !challengeInfo) {
// optimistic skeleton
return (
Expand All @@ -331,8 +404,8 @@ export function WinPage() {
);
}

const didWin = !!challengeUserInfo.solvedAtMs;
const word = challengeUserInfo.guesses?.find((x: any) => x.similarity === 1);
const didWin = Boolean(challengeUserInfo.solvedAtMs || word);

const calculatePercentageOutperformed = (rank: number, totalPlayers: number): number => {
if (rank === 1) return 100;
Expand Down
18 changes: 15 additions & 3 deletions src/client/classic/WinPageLoggedOut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from 'preact/hooks';
import { trpc } from '../trpc';
import type { GuessEngine } from '../core/guessEngine';
import { requireChallengeNumber } from '../requireChallengeNumber';
import { showShareSheet, showToast } from '@devvit/web/client';
import { showLoginPrompt, showShareSheet, showToast } from '@devvit/web/client';
import { posthog } from '../posthog';

export function WinPageLoggedOut({ engine }: { engine: GuessEngine }) {
Expand Down Expand Up @@ -67,10 +67,22 @@ export function WinPageLoggedOut({ engine }: { engine: GuessEngine }) {
<p className="text-xl">
The word was: <span className="font-bold text-[#dd4c4c]">{secretWord ?? '...'}</span>
</p>
<p className="text-lg text-gray-600 dark:text-gray-300 mt-4">
Sign up to see the full leaderboard and save your progress.
<p className="text-lg text-gray-600 dark:text-gray-300">
Log in to see the full leaderboard and save your progress.
</p>
</div>
{wonLocally && (
<button
type="button"
onClick={() => {
posthog.capture('Win Page Logged Out Login Clicked', { challengeNumber });
showLoginPrompt();
}}
className="rounded-full bg-black px-5 py-3 text-sm font-semibold text-white focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-50 dark:bg-white dark:text-black"
>
Log In
</button>
)}
<button
type="button"
onClick={() => {
Expand Down
13 changes: 12 additions & 1 deletion src/client/classic/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
loadPreviousGuessesFromSession,
selectNextHint,
} from '../core/hints';
import { context, showShareSheet, showToast } from '@devvit/web/client';
import { context, showLoginPrompt, showShareSheet, showToast } from '@devvit/web/client';
import { requireChallengeNumber } from '../requireChallengeNumber';
import { userSettings, toggleLayout, toggleSortType, setReminderOptIn } from './state/userSettings';
import { trpc } from '../trpc';
Expand Down Expand Up @@ -245,6 +245,17 @@ export function Header({ engine, isAdmin }: { engine?: GuessEngine; isAdmin: boo
}
},
},
...(isLoggedOut()
? ([
{
name: 'Log In',
action: () => {
posthog.capture('Game Page Logged Out Login Clicked', { challengeNumber });
showLoginPrompt();
},
},
] as const)
: ([] as const)),
...(isAdmin
? ([
{
Expand Down
55 changes: 55 additions & 0 deletions src/client/core/guessEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,61 @@ describe('guessEngine', () => {
]);
});

it('reconciles on logged-in init by submitting local guesses missing from server', async () => {
window.localStorage.setItem(
'guess-history:55',
JSON.stringify([
{ word: 'alpha', similarity: 0.2, rank: 20, timestamp: 1 },
{ word: 'beta', similarity: 0.3, rank: 10, timestamp: 2 },
])
);

trpcMock.game.get.query
.mockResolvedValueOnce({
challengeUserInfo: {
guesses: [{ word: 'alpha', similarity: 0.2, rank: 20, timestampMs: 1 }],
},
})
.mockResolvedValueOnce({
challengeUserInfo: {
guesses: [
{ word: 'alpha', similarity: 0.2, rank: 20, timestampMs: 1 },
{ word: 'beta', similarity: 0.3, rank: 10, timestampMs: 2 },
],
},
});

trpcMock.guess.submitBatch.mutate.mockResolvedValueOnce({ challengeUserInfo: {} });

const engine = createGuessEngine({ challengeNumber: 55 });
await Promise.resolve();
await Promise.resolve();

expect(trpcMock.guess.submitBatch.mutate).toHaveBeenCalledWith({
challengeNumber: 55,
guesses: [{ word: 'beta', similarity: 0.3, rank: 10, atMs: 2 }],
});
expect(trpcMock.game.get.query).toHaveBeenCalledTimes(2);
expect(engine.history.value).toEqual([
{ word: 'alpha', similarity: 0.2, rank: 20, timestamp: 1 },
{ word: 'beta', similarity: 0.3, rank: 10, timestamp: 2 },
]);
});

it('does not import local guesses on init when logged out', async () => {
window.localStorage.setItem(
'guess-history:56',
JSON.stringify([{ word: 'guest', similarity: 0.6, rank: 6, timestamp: 3 }])
);
trpcMock.game.get.query.mockResolvedValueOnce({ challengeUserInfo: null });

const engine = createGuessEngine({ challengeNumber: 56 });
await Promise.resolve();

expect(trpcMock.guess.submitBatch.mutate).not.toHaveBeenCalled();
expect(engine.history.value).toEqual([{ word: 'guest', similarity: 0.6, rank: 6, timestamp: 3 }]);
});

it('submits guesses to server in the background and uses returned solvedAtMs if provided', async () => {
const engine = createGuessEngine({ challengeNumber: 30, rateLimitMs: 1 });
makeGuessMock.mockResolvedValueOnce({ word: 'close', similarity: 0.9, rank: 2 });
Expand Down
68 changes: 55 additions & 13 deletions src/client/core/guessEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ const submitBatchToServer = async (
}
};

const RECONCILE_BATCH_SIZE = 25;

const toWordKey = (word: string): string => String(word ?? '').trim().toLowerCase();

// ---------------------------------------------------------
// Engine factory
// ---------------------------------------------------------
Expand Down Expand Up @@ -170,25 +174,63 @@ export function createGuessEngine(params: {
void (async () => {
const localHistorySnapshot: GuessHistoryItem[] = history.value.slice();
try {
const parseServerHistory = (challengeUserInfo: any): GuessHistoryItem[] =>
challengeUserInfo.guesses.map((g: any) => {
const s = Number(g.similarity);
const r = Number(g.rank);
const t = Number(g.timestampMs);
return {
word: String(g.word ?? ''),
similarity: Number.isFinite(s) ? s : 0,
rank: Number.isFinite(r) ? r : -1,
timestamp: Number.isFinite(t) ? t : Date.now(),
};
});

const server = await trpc.game.get.query({ challengeNumber });
if (!server.challengeUserInfo) {
return;
}
const serverWords = server.challengeUserInfo.guesses.map((g: any) => g.word);
const serverHistory: GuessHistoryItem[] = server.challengeUserInfo.guesses.map((g: any) => {
const s = Number(g.similarity);
const r = Number(g.rank);
const t = Number(g.timestampMs);
return {
word: String(g.word ?? ''),
similarity: Number.isFinite(s) ? s : 0,
rank: Number.isFinite(r) ? r : -1,
timestamp: Number.isFinite(t) ? t : Date.now(),
};
});
let latestChallengeUserInfo = server.challengeUserInfo;
let serverHistory: GuessHistoryItem[] = parseServerHistory(latestChallengeUserInfo);

// Logged-in reconciliation: import local-only guesses into the account on init.
if (localHistorySnapshot.length > 0) {
const serverWordSet = new Set(serverHistory.map((item) => toWordKey(item.word)));
const missingLocalGuesses = localHistorySnapshot
.filter((item) => !serverWordSet.has(toWordKey(item.word)))
.sort((a, b) => a.timestamp - b.timestamp)
.map((item) => ({
word: item.word,
similarity: item.similarity,
rank: Number.isFinite(item.rank) ? item.rank : -1,
atMs: Number.isFinite(item.timestamp) ? item.timestamp : Date.now(),
}));

if (missingLocalGuesses.length > 0) {
for (let i = 0; i < missingLocalGuesses.length; i += RECONCILE_BATCH_SIZE) {
const chunk = missingLocalGuesses.slice(i, i + RECONCILE_BATCH_SIZE);
try {
await submitBatchToServer(challengeNumber, chunk);
} catch {
// Ignore per-batch failures; a later fetch reconciles remaining state.
}
}
try {
const refreshed = await trpc.game.get.query({ challengeNumber });
if (refreshed.challengeUserInfo) {
latestChallengeUserInfo = refreshed.challengeUserInfo;
serverHistory = parseServerHistory(latestChallengeUserInfo);
}
} catch {
// Ignore refresh failures and continue with best known server snapshot.
}
}
}
const serverWords = serverHistory.map((item) => item.word);

// If server indicates solved, mark immediately
const serverSolvedAt = server?.challengeUserInfo?.solvedAtMs;
const serverSolvedAt = latestChallengeUserInfo?.solvedAtMs;
if (serverSolvedAt && Number.isFinite(Number(serverSolvedAt))) {
solvedAtMs.value = Number(serverSolvedAt);
markSolvedForCurrentChallenge(solvedAtMs.value);
Expand Down