fix(game): allow duplicate chain links with a scoring penalty

Mirrors the server-side change: reusing an actor or movie was rejected,
but project-plan.md section 4 calls for a points penalty instead, so that
a player is never stranded. Reuse is now accepted with a non-blocking
warning that says it costs points, and the penalty shows up in the score
breakdown at completion.

Also selects store actions individually rather than destructuring the
whole store, which was re-rendering every consumer on each keystroke.
This commit is contained in:
TehRiehlDeal committed 2026-09-09 19:58:50 -07:00
1 parent 31e2600f60
commit 70d53ed37d
1 file changed
+19 -18
+19 -18
View File
@@ -1,3 +1,4 @@
import { toast } from 'sonner';
import { useCallback } from 'react';
import { useGameStore } from '@/stores/game-store';
import { validateLink } from '@/api/games';
@@ -16,13 +17,14 @@ import type {
* When a backend session exists, validation is delegated to the server.
*/
export function useChainValidation() {
const {
chain,
addActorToChain,
addMovieToChain,
setValidating,
setValidationError,
} = useGameStore();
// Selected individually rather than destructured off the whole store: the
// unselected form re-rendered every consumer on any state change, including
// each keystroke-driven isValidating flip. These are stable action refs, so
// the callbacks below now keep a stable identity too.
const addActorToChain = useGameStore((s) => s.addActorToChain);
const addMovieToChain = useGameStore((s) => s.addMovieToChain);
const setValidating = useGameStore((s) => s.setValidating);
const setValidationError = useGameStore((s) => s.setValidationError);
const validateAndAddActor = useCallback(
async (person: TmdbPersonResult) => {
@@ -35,11 +37,12 @@ export function useChainValidation() {
return;
}
// Quick client-side duplicate check (fast feedback)
// Duplicates are allowed by design and charged at scoring time, so this
// is a heads-up rather than a rejection.
if (currentChain.some((link) => link.type === 'actor' && link.id === person.id)) {
setValidationError(`${person.name} is already in your chain.`);
playSound('invalid');
return;
toast.warning(`${person.name} is already in your chain`, {
description: 'Reusing a link costs you points.',
});
}
setValidating(true);
@@ -86,7 +89,7 @@ export function useChainValidation() {
setValidating(false);
}
},
[chain, addActorToChain, setValidating, setValidationError],
[addActorToChain, setValidating, setValidationError],
);
const validateAndAddMovie = useCallback(
@@ -103,16 +106,14 @@ export function useChainValidation() {
// Quick client-side duplicate / loop-closure checks
const isMovieA = movie.id === movieA?.id;
const isMovieB = movie.id === movieB?.id;
if (
!isMovieA &&
!isMovieB &&
currentChain.some((link) => link.type === 'movie' && link.id === movie.id)
) {
setValidationError(`"${movie.title}" is already in your chain.`);
playSound('invalid');
return;
toast.warning(`"${movie.title}" is already in your chain`, {
description: 'Reusing a link costs you points.',
});
}
if (isMovieA && !passedMovieB) {
@@ -169,7 +170,7 @@ export function useChainValidation() {
setValidating(false);
}
},
[chain, addMovieToChain, setValidating, setValidationError],
[addMovieToChain, setValidating, setValidationError],
);
return { validateAndAddActor, validateAndAddMovie };