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:
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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user