fix(game): create a backend session for multiplayer games

Versus, Game Night and Async Versus could never complete a match.
startGameFromVersus was a copy of startGame minus the session-creation
block, so sessionId stayed null. score is only ever assigned inside the
`if (sessionId)` branch of the completion handler, and every multiplayer
submission is gated on score being non-null - so the submit never fired,
the opponent was never told, and the match never resolved. The completion
modals had no dismiss path, making it an unrecoverable dead end.

- startGameFromVersus now creates the session and reports the outcome as
  'started' | 'superseded' | 'failed'. The middle case matters: React
  StrictMode remounts the effect and its cleanup resets the store, which
  must not be reported to the player as an error.
- Completion moves into finalizeGame, which awaits an in-flight session
  rather than racing it (a fast player could close the loop first) and
  always resolves to a score or a visible error, never a silent null.
- A generation counter retires work belonging to abandoned games, so a
  slow session-creation response can no longer attach itself to the
  next game.
- Achievements are no longer stored on `score`. They were posted back to
  the API, where forbidNonWhitelisted rejected the submission with a 400.
- A failed async submit is no longer retried on every render; it
  previously fired ~20 identical failing requests.
- The versus and game-night completion modals gained a way out so a
  stalled opponent is not a trap.

Verified end to end: an async match played to completion submits, and
Postgres records the score against both the match and the session.
This commit is contained in:
TehRiehlDeal committed 2026-09-09 19:58:50 -07:00
1 parent ba16cbde38
commit 31e2600f60
6 files changed
+220 -48

No files matched your search

@@ -20,6 +20,8 @@ export default function GameNightCompletionModal() {
const finishedPlayers = useGameNightStore((s) => s.finishedPlayers);
const forfeitedPlayers = useGameNightStore((s) => s.forfeitedPlayers);
const roomId = useGameNightStore((s) => s.roomId);
const leaveRoom = useGameNightStore((s) => s.leaveRoom);
const reset = useGameNightStore((s) => s.reset);
const isOpen = status === 'completed' || lobbyState === 'finished';
@@ -34,6 +36,14 @@ export default function GameNightCompletionModal() {
}
};
// Players who stall or drop must not strand everyone else in a modal whose
// only control appears once the room reaches 'finished'.
const handleLeave = () => {
leaveRoom();
reset();
navigate('/game-night');
};
return (
<Dialog open={isOpen}>
<DialogContent className="max-w-md">
@@ -82,6 +92,15 @@ export default function GameNightCompletionModal() {
);
})}
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleLeave}
>
Leave game
</Button>
</div>
)}
@@ -45,6 +45,11 @@ export default function VersusCompletionModal() {
<Loader2 className="h-4 w-4 animate-spin" />
<span>Waiting for opponent to finish...</span>
</div>
{/* An opponent who never finishes must not strand this player in
a modal with no controls. */}
<Button variant="outline" size="sm" onClick={handleBackToVersus}>
Back to Versus
</Button>
</div>
</>
) : matchResult ? (
+28 -5
View File
@@ -77,25 +77,35 @@ export default function AsyncVersusGame() {
return;
}
setMode('creator');
await startGameFromVersus(
const started = await startGameFromVersus(
{
movieA: { id: match.movieAId, title: match.movieATitle! },
movieB: { id: match.movieBId, title: match.movieBTitle! },
},
Date.now(),
);
if (started === 'superseded') return;
if (started === 'failed') {
setError('Could not start this match. Please try again.');
return;
}
} else {
// Challenger mode
const result = await startAsyncAttempt(matchId!);
setMode('challenger');
setTargetChainLength(result.chainLength);
await startGameFromVersus(
const started = await startGameFromVersus(
{
movieA: { id: result.movieAId, title: result.movieATitle },
movieB: { id: result.movieBId, title: result.movieBTitle },
},
Date.now(),
);
if (started === 'superseded') return;
if (started === 'failed') {
setError('Could not start this match. Please try again.');
return;
}
}
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
@@ -111,11 +121,14 @@ export default function AsyncVersusGame() {
return () => {
resetGame();
};
}, [matchId, user, authLoading, navigate]);
}, [matchId, user, authLoading, navigate, resetGame, startGameFromVersus]);
// Handle game completion
useEffect(() => {
if (status !== 'completed' || !score || !matchId || submitting || showCompletion) return;
// `error` participates so a failed submission is not retried on every
// subsequent render — it previously fired ~20 identical failing requests.
if (status !== 'completed' || !score || !matchId || submitting || showCompletion || error)
return;
setSubmitting(true);
const chainLength = chain.length;
@@ -146,7 +159,17 @@ export default function AsyncVersusGame() {
setSubmitting(false);
}
})();
}, [status, score, matchId, mode, submitting, showCompletion]);
}, [
status,
score,
matchId,
mode,
submitting,
showCompletion,
chain.length,
user?.id,
error,
]);
if (!user) return null;
+7 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import PageLayout from '@/components/layout/PageLayout';
@@ -44,10 +45,15 @@ export default function GameNightLobby() {
movieB: MoviePair['movieB'];
startTime: number;
}) => {
await startGameFromVersus(
const result = await startGameFromVersus(
{ movieA: data.movieA, movieB: data.movieB },
data.startTime,
);
if (result === 'superseded') return;
if (result === 'failed') {
toast.error('Could not start the game. Please try again.');
return;
}
navigate('/game-night/play');
},
[startGameFromVersus, navigate],
+10 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import PageLayout from '@/components/layout/PageLayout';
import { useAuthStore } from '@/stores/auth-store';
@@ -32,10 +33,18 @@ export default function VersusLobby() {
const handleGameStart = useCallback(
async (data: { movieA: MoviePair['movieA']; movieB: MoviePair['movieB']; startTime: number }) => {
await startGameFromVersus(
const result = await startGameFromVersus(
{ movieA: data.movieA, movieB: data.movieB },
data.startTime,
);
// A newer start is already in charge; saying anything here would be wrong.
if (result === 'superseded') return;
// Navigating on a failed start used to bounce straight back here via
// VersusGame's idle guard, with nothing explaining why.
if (result === 'failed') {
toast.error('Could not start the match. Please try again.');
return;
}
navigate('/versus/game');
},
[startGameFromVersus, navigate],
+151 -41
View File
@@ -30,7 +30,7 @@ interface GameState {
dailyChallengeDifficulty: string | null;
startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => Promise<void>;
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<void>;
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<StartResult>;
addActorToChain: (actor: ActorChainLink) => void;
addMovieToChain: (movie: MovieChainLink) => void;
undoLastLink: () => void;
@@ -59,10 +59,108 @@ const initialState = {
dailyChallengeDifficulty: null as string | null,
};
/**
* Outcome of starting a multiplayer game.
* - 'started' play can begin
* - 'superseded' a newer game replaced this one mid-start (React StrictMode
* remounts an effect, and its cleanup resets the store) — the
* caller must stay silent, because another start is in charge
* - 'failed' genuinely could not start; the caller should surface it
*/
export type StartResult = 'started' | 'superseded' | 'failed';
interface SessionArgs {
movieAId: number;
movieATitle: string;
movieBId: number;
movieBTitle: string;
movieAReleaseDate: string | null;
movieBReleaseDate: string | null;
dailyChallengeId?: string;
}
// Every started game takes the next generation number. Anything async that was
// kicked off for an older generation must not write back into the store — that
// is how a slow session-creation call used to land its id in the *next* game.
let gameGeneration = 0;
let sessionCreation: Promise<string | null> = Promise.resolve(null);
async function createSession(
generation: number,
args: SessionArgs,
): Promise<string | null> {
try {
const { createGameSession } = await import('@/api/games');
const session = await createGameSession(
args.movieAId,
args.movieATitle,
args.movieBId,
args.movieBTitle,
args.dailyChallengeId,
args.movieAReleaseDate,
args.movieBReleaseDate,
);
if (generation !== gameGeneration) return null;
useGameStore.setState({ sessionId: session.id });
return session.id;
} catch (err) {
console.error('[GameStore] Failed to create game session:', err);
return null;
}
}
// Resolves the final score once the loop closes. Split out of addMovieToChain
// so it can await a session that may still be in flight: a fast player can
// close the loop before creation returns, which previously left score null
// forever with no error path.
async function finalizeGame(
generation: number,
chain: ChainLink[],
hintsUsed: number,
): Promise<void> {
const stillCurrent = () => generation === gameGeneration;
try {
const sessionId =
useGameStore.getState().sessionId ?? (await sessionCreation);
if (!stillCurrent()) return;
if (!sessionId) {
useGameStore.setState({
validationError:
'Your loop is complete, but the server never registered this game, so it could not be scored.',
});
return;
}
const { completeGameSession } = await import('@/api/games');
const backendScore = await completeGameSession(sessionId, chain, hintsUsed);
if (!stillCurrent()) return;
// Achievements are a completion side effect, not part of the score. Storing
// them on `score` meant versus/game-night posted them straight back to the
// API, where forbidNonWhitelisted rejected the whole submission with a 400.
const { newAchievements, ...breakdown } = backendScore;
useGameStore.setState({ score: breakdown });
if (newAchievements?.length) {
for (const achievement of newAchievements) {
toast.success(`Achievement Unlocked: ${achievement.name}`, {
description: achievement.description,
duration: 5000,
});
}
}
} catch {
if (!stillCurrent()) return;
useGameStore.setState({ validationError: 'Failed to calculate score.' });
}
}
export const useGameStore = create<GameState>((set, get) => ({
...initialState,
startGame: async (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => {
const generation = ++gameGeneration;
// Reset if already playing
set({ ...initialState, status: 'playing', isValidating: true, isCustomGame: options?.isCustom ?? false, isDailyChallenge: options?.isDaily ?? false, dailyChallengeDate: options?.dailyDate ?? null, dailyChallengeDifficulty: options?.difficulty ?? null });
@@ -99,22 +197,17 @@ export const useGameStore = create<GameState>((set, get) => ({
isValidating: false,
});
// Fire-and-forget backend session creation
import('@/api/games').then(({ createGameSession }) => {
createGameSession(
pair.movieA.id,
pair.movieA.title,
pair.movieB.id,
pair.movieB.title,
options?.dailyChallengeId,
detailsA.release_date ?? null,
detailsB.release_date ?? null,
)
.then((session) => set({ sessionId: session.id }))
.catch((err) => {
// Backend unavailable — game continues without persistence
console.error('[GameStore] Failed to create game session:', err);
});
// Single-player deliberately keeps session creation off the critical path
// so play starts instantly and still works when the backend is down. The
// promise is retained so completion can await it instead of racing it.
sessionCreation = createSession(generation, {
movieAId: pair.movieA.id,
movieATitle: pair.movieA.title,
movieBId: pair.movieB.id,
movieBTitle: pair.movieB.title,
dailyChallengeId: options?.dailyChallengeId,
movieAReleaseDate: detailsA.release_date ?? null,
movieBReleaseDate: detailsB.release_date ?? null,
});
} catch {
set({
@@ -126,6 +219,7 @@ export const useGameStore = create<GameState>((set, get) => ({
},
startGameFromVersus: async (pair: MoviePair, startTime: number) => {
const generation = ++gameGeneration;
set({ ...initialState, status: 'playing', isValidating: true });
try {
@@ -152,20 +246,50 @@ export const useGameStore = create<GameState>((set, get) => ({
popularity: detailsB.popularity,
};
if (generation !== gameGeneration) return 'superseded';
// Unlike single-player, multiplayer cannot degrade to an unpersisted game:
// no session means no score, and no score means the match never resolves
// for either player. So this is awaited, and failing here is fatal.
sessionCreation = createSession(generation, {
movieAId: pair.movieA.id,
movieATitle: pair.movieA.title,
movieBId: pair.movieB.id,
movieBTitle: pair.movieB.title,
movieAReleaseDate: detailsA.release_date ?? null,
movieBReleaseDate: detailsB.release_date ?? null,
});
const sessionId = await sessionCreation;
if (generation !== gameGeneration) return 'superseded';
if (!sessionId) {
set({
status: 'idle',
isValidating: false,
validationError:
'Could not start the match — the server did not register this game.',
});
return 'failed';
}
set({
movieA: movieALink,
movieB: movieBLink,
chain: [movieALink],
currentSearchMode: 'actor',
startTime,
sessionId,
isValidating: false,
});
return 'started';
} catch {
if (generation !== gameGeneration) return 'superseded';
set({
status: 'idle',
isValidating: false,
validationError: 'Failed to load movie details. Please try again.',
});
return 'failed';
}
},
@@ -206,29 +330,10 @@ export const useGameStore = create<GameState>((set, get) => ({
validationError: null,
});
// Await backend score calculation
const { sessionId } = get();
if (sessionId) {
import('@/api/games').then(({ completeGameSession }) => {
completeGameSession(sessionId, newChain, hintsUsed)
.then((backendScore) => {
set({ score: backendScore });
// Show toast for each newly earned achievement
if (backendScore.newAchievements?.length) {
for (const achievement of backendScore.newAchievements) {
toast.success(`Achievement Unlocked: ${achievement.name}`, {
description: achievement.description,
duration: 5000,
});
}
}
})
.catch(() => {
set({ validationError: 'Failed to calculate score.' });
});
});
}
// Resolve the score out of band. finalizeGame awaits an in-flight session
// and always lands on either a score or a validationError — never on the
// silent null that left completion modals with nothing to render.
void finalizeGame(gameGeneration, newChain, hintsUsed);
return;
}
@@ -283,5 +388,10 @@ export const useGameStore = create<GameState>((set, get) => ({
incrementHintsUsed: () =>
set((state) => ({ hintsUsed: state.hintsUsed + 1 })),
resetGame: () => set(initialState),
resetGame: () => {
// Retire the generation so any in-flight session creation or scoring call
// for the abandoned game cannot write back into the fresh state.
gameGeneration++;
set(initialState);
},
}));