fix(game): follow the server-authoritative scoring contract

The server no longer accepts a score, a chain or a hint count from the
client, so the callers change to match.

- completeGameSession posts no payload; the server scores the chain it
  validated and the hints it recorded.
- Hints go through POST /games/:id/hint, which is what makes the hint
  penalty enforceable - it was previously counted in the browser and
  self-reported at completion, so it was effectively optional. The server
  also picks the actors, replacing a client-side shuffle that used
  sort(() => Math.random() - 0.5), which is neither uniform nor a valid
  comparator.
- Versus, game-night and async submissions send the session id instead of
  a ScoreBreakdown. Sending a score object is what allowed any player to
  win any match by posting a large enough number.
- Stale hints are cleared when the chain changes; the previous movie's
  cast used to stay on screen after picking an actor another way.

Verified end to end: a custom game with one hint and one duplicate link
scores 2519, with the -200 hint penalty and -150 duplicate penalty both
applied from server-side state, and the obscurity bonus computed from real
TMDB popularity rather than anything the client claimed.
This commit is contained in:
TehRiehlDeal committed 2026-09-09 20:57:20 -07:00
1 parent b873e44dd0
commit da977e6fb5
9 files changed
+103 -70

No files matched your search

+26 -6
View File
@@ -27,15 +27,35 @@ export async function createGameSession(
return data;
}
/**
* No payload. The server scores the chain it validated link by link and the
* hint count it incremented; sending our own was how a chain that had never
* been validated could be scored.
*/
export async function completeGameSession(
id: string,
chain: ChainLink[],
hintsUsed: number,
): Promise<ScoreBreakdown> {
const { data } = await apiClient.post<ScoreBreakdown>(`/games/${id}/complete`, {
chain,
hintsUsed,
});
const { data } = await apiClient.post<ScoreBreakdown>(
`/games/${id}/complete`,
{},
);
return data;
}
export interface HintResponse {
hintsUsed: number;
hintsRemaining: number;
actors: {
id: number;
name: string;
profilePath: string | null;
popularity: number;
}[];
}
/** Takes a hint. The server records it, which is what makes the penalty real. */
export async function useHint(id: string): Promise<HintResponse> {
const { data } = await apiClient.post<HintResponse>(`/games/${id}/hint`, {});
return data;
}
+11 -15
View File
@@ -80,15 +80,15 @@ export async function getUserMatchHistory(page = 1, limit = 20) {
return data;
}
/** Sends the session to score from; the server will not accept a score. */
export async function submitCreatorScore(
matchId: string,
score: object,
chainLength: number,
sessionId: string,
): Promise<VersusMatch> {
const { data } = await apiClient.post<VersusMatch>(`/versus/${matchId}/creator-score`, {
score,
chainLength,
});
const { data } = await apiClient.post<VersusMatch>(
`/versus/${matchId}/creator-score`,
{ sessionId },
);
return data;
}
@@ -114,15 +114,11 @@ export async function startAsyncAttempt(
return data;
}
export async function submitAsyncAttempt(
matchId: string,
score: object,
chainLength: number,
) {
const { data } = await apiClient.post(`/versus/${matchId}/async-attempt/submit`, {
score,
chainLength,
});
export async function submitAsyncAttempt(matchId: string, sessionId: string) {
const { data } = await apiClient.post(
`/versus/${matchId}/async-attempt/submit`,
{ sessionId },
);
return data;
}
+29 -20
View File
@@ -1,19 +1,20 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useGameStore } from '@/stores/game-store';
import { getMovieCredits } from '@/api/movies';
import { useHint as requestHint } from '@/api/games';
import type { TmdbCastMember } from '@/types';
const MAX_HINTS = 3;
const HINTS_PER_REQUEST = 3;
export function useHints() {
const [hints, setHints] = useState<TmdbCastMember[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const chain = useGameStore((s) => s.chain);
const currentSearchMode = useGameStore((s) => s.currentSearchMode);
const hintsUsed = useGameStore((s) => s.hintsUsed);
const incrementHintsUsed = useGameStore((s) => s.incrementHintsUsed);
const setHintsUsed = useGameStore((s) => s.setHintsUsed);
const sessionId = useGameStore((s) => s.sessionId);
const status = useGameStore((s) => s.status);
// Can only hint when looking for an actor after a movie
@@ -23,39 +24,47 @@ export function useHints() {
currentSearchMode === 'actor' &&
lastLink?.type === 'movie' &&
hintsUsed < MAX_HINTS &&
!!sessionId &&
!isLoading;
const getHint = useCallback(async () => {
if (!canHint || lastLink?.type !== 'movie') return;
if (!canHint || !sessionId) return;
setIsLoading(true);
setError(null);
try {
const credits = await getMovieCredits(lastLink.id);
// Filter out actors already in the chain
const chainActorIds = new Set(
chain.filter((l) => l.type === 'actor').map((l) => l.id),
// The server picks the actors and records the hint. Doing this in the
// browser meant the hint count - and so the hint penalty - was whatever
// the client chose to report at completion time.
const result = await requestHint(sessionId);
setHints(
result.actors.map((actor) => ({
id: actor.id,
name: actor.name,
profile_path: actor.profilePath,
popularity: actor.popularity,
})) as TmdbCastMember[],
);
const available = credits.cast.filter(
(member) => !chainActorIds.has(member.id),
);
// Pick random actors from available cast
const shuffled = [...available].sort(() => Math.random() - 0.5);
setHints(shuffled.slice(0, HINTS_PER_REQUEST));
incrementHintsUsed();
setHintsUsed(result.hintsUsed);
} catch {
// silently fail
setError('Could not fetch a hint. Please try again.');
} finally {
setIsLoading(false);
}
}, [canHint, lastLink, chain, incrementHintsUsed]);
}, [canHint, sessionId, setHintsUsed]);
const clearHints = useCallback(() => setHints([]), []);
// Stale hints used to linger after the player picked an actor some other
// way, leaving the previous movie's cast on screen.
useEffect(() => {
setHints([]);
}, [chain.length]);
return {
hints,
isLoading,
error,
getHint,
clearHints,
canHint,
+13 -5
View File
@@ -39,6 +39,7 @@ export default function AsyncVersusGame() {
const movieB = useGameStore((s) => s.movieB);
const isValidating = useGameStore((s) => s.isValidating);
const score = useGameStore((s) => s.score);
const sessionId = useGameStore((s) => s.sessionId);
const undoLastLink = useGameStore((s) => s.undoLastLink);
const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
const resetGame = useGameStore((s) => s.resetGame);
@@ -127,18 +128,25 @@ export default function AsyncVersusGame() {
useEffect(() => {
// `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)
if (
status !== 'completed' ||
!score ||
!matchId ||
!sessionId ||
submitting ||
showCompletion ||
error
)
return;
setSubmitting(true);
const chainLength = chain.length;
(async () => {
try {
if (mode === 'creator') {
await submitCreatorScore(matchId, score, chainLength);
await submitCreatorScore(matchId, sessionId);
} else {
await submitAsyncAttempt(matchId, score, chainLength);
await submitAsyncAttempt(matchId, sessionId);
// Fetch leaderboard to get rank and creator score
try {
const lb = await getAsyncLeaderboard(matchId);
@@ -166,7 +174,7 @@ export default function AsyncVersusGame() {
mode,
submitting,
showCompletion,
chain.length,
sessionId,
user?.id,
error,
]);
+4 -4
View File
@@ -30,6 +30,7 @@ export default function GameNightPlay() {
const sendChainUpdate = useGameNightStore((s) => s.sendChainUpdate);
const sendPlayerComplete = useGameNightStore((s) => s.sendPlayerComplete);
const sessionId = useGameStore((s) => s.sessionId);
const sendPlayerForfeit = useGameNightStore((s) => s.sendPlayerForfeit);
const isSpectator = useGameNightStore((s) => s.isSpectator);
const lobbyState = useGameNightStore((s) => s.lobbyState);
@@ -44,11 +45,10 @@ export default function GameNightPlay() {
// Send completion when game completes
useEffect(() => {
if (status === 'completed' && score && !isSpectator) {
const linkCount = Math.floor((chain.length - 1) / 2);
sendPlayerComplete(score, linkCount);
if (status === 'completed' && score && sessionId && !isSpectator) {
sendPlayerComplete(sessionId);
}
}, [status, score, chain.length, sendPlayerComplete, isSpectator]);
}, [status, score, sessionId, sendPlayerComplete, isSpectator]);
useEffect(() => {
if (status === 'idle') {
+4 -3
View File
@@ -29,6 +29,7 @@ export default function VersusGame() {
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
const sendChainUpdate = useVersusStore((s) => s.sendChainUpdate);
const sendMatchComplete = useVersusStore((s) => s.sendMatchComplete);
const sessionId = useGameStore((s) => s.sessionId);
// Broadcast chain updates to opponent
useEffect(() => {
@@ -39,10 +40,10 @@ export default function VersusGame() {
// Send match completion when game completes
useEffect(() => {
if (status === 'completed' && score) {
sendMatchComplete(score);
if (status === 'completed' && score && sessionId) {
sendMatchComplete(sessionId, score);
}
}, [status, score, sendMatchComplete]);
}, [status, score, sessionId, sendMatchComplete]);
useEffect(() => {
if (status === 'idle') {
+3 -3
View File
@@ -32,7 +32,7 @@ interface GameNightState {
toggleReady: () => void;
startCountdown: () => void;
sendChainUpdate: (chainLength: number) => void;
sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => void;
sendPlayerComplete: (sessionId: string) => void;
sendPlayerForfeit: () => void;
leaveRoom: () => void;
reset: () => void;
@@ -219,10 +219,10 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
socket.emit('chain-update', { roomId, chainLength });
},
sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => {
sendPlayerComplete: (sessionId: string) => {
const { socket, roomId } = get();
if (!socket || !roomId) return;
socket.emit('player-complete', { roomId, score, chainLength });
socket.emit('player-complete', { roomId, sessionId });
},
sendPlayerForfeit: () => {
+7 -11
View File
@@ -36,7 +36,8 @@ interface GameState {
undoLastLink: () => void;
setValidating: (isValidating: boolean) => void;
setValidationError: (error: string | null) => void;
incrementHintsUsed: () => void;
/** Set from the server's response; the count is no longer tracked locally. */
setHintsUsed: (count: number) => void;
resetGame: () => void;
}
@@ -113,11 +114,7 @@ async function createSession(
// 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> {
async function finalizeGame(generation: number): Promise<void> {
const stillCurrent = () => generation === gameGeneration;
try {
const sessionId =
@@ -133,7 +130,7 @@ async function finalizeGame(
}
const { completeGameSession } = await import('@/api/games');
const backendScore = await completeGameSession(sessionId, chain, hintsUsed);
const backendScore = await completeGameSession(sessionId);
if (!stillCurrent()) return;
// Achievements are a completion side effect, not part of the score. Storing
@@ -305,7 +302,7 @@ export const useGameStore = create<GameState>((set, get) => ({
},
addMovieToChain: (movie: MovieChainLink) => {
const { status, chain, movieA, movieB, passedMovieB, hintsUsed } = get();
const { status, chain, movieA, movieB, passedMovieB } = get();
if (status !== 'playing') return;
const newChain = [...chain, movie];
@@ -333,7 +330,7 @@ export const useGameStore = create<GameState>((set, get) => ({
// 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);
void finalizeGame(gameGeneration);
return;
}
@@ -385,8 +382,7 @@ export const useGameStore = create<GameState>((set, get) => ({
setValidationError: (error: string | null) =>
set({ validationError: error }),
incrementHintsUsed: () =>
set((state) => ({ hintsUsed: state.hintsUsed + 1 })),
setHintsUsed: (count: number) => set({ hintsUsed: count }),
resetGame: () => {
// Retire the generation so any in-flight session creation or scoring call
+6 -3
View File
@@ -39,7 +39,7 @@ interface VersusState {
joinLobby: (matchId: string) => void;
startCountdown: () => void;
sendChainUpdate: (chainLength: number) => void;
sendMatchComplete: (score: ScoreBreakdown) => void;
sendMatchComplete: (sessionId: string, score: ScoreBreakdown) => void;
leaveLobby: () => void;
reset: () => void;
}
@@ -185,11 +185,14 @@ export const useVersusStore = create<VersusState>((set, get) => ({
socket.emit('chain-update', { matchId, chainLength });
},
sendMatchComplete: (score: ScoreBreakdown) => {
sendMatchComplete: (sessionId: string, score: ScoreBreakdown) => {
const { socket, matchId } = get();
if (!socket || !matchId) return;
// The score is kept locally only so this client can render its own result
// immediately; the server scores from the session and ignores anything we
// would otherwise send.
set({ myScore: score });
socket.emit('match-complete', { matchId, score });
socket.emit('match-complete', { matchId, sessionId });
},
leaveLobby: () => {