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:
1 parent
b873e44dd0
commit
da977e6fb5
9 files changed
+103
-70
No files matched your search
+26
-6
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
Reference in new issue
Block a user