Skip to content

Commit 826fe59

Browse files
B2JK-Industryclaude
andcommitted
fix(games): PR-P G-37 — shuffle AI client items + options per session
User reports: 'v tej novej hre boli všetky správne odpovede na jednom mieste, a u niektorých typoch hier sa pozície správnych odpovedí nemenia'. Two compounding causes: 1. The Sonnet generator's correctIndex distribution is lopsided — for quiz / chart-read it picks index 0 disproportionately often, so several questions in a row showed the right answer in the same position. 2. AI specs are deterministic by content-hash. Replaying the same envelope (the spec lives in Redis until rotation) showed the same item sequence + same option arrangement every time, training pattern-matching instead of comprehension. Fix per AI client: shuffle on mount via `useState` initializer (runs ONCE per mount, NOT on every re-render — re-shuffling mid- question would be jarring). Same Fisher-Yates from `lib/shuffle.ts` the evergreen `pickRound` helper uses on /games/finance-quiz. Per-client treatment: - `ai-quiz-client.tsx` — shuffle items + each item's 4 options + recompute `correctIndex` to track the new option position. This is the most user-visible fix (multi-question, multi-option). - `ai-truefalse-client.tsx` — items only (statement order); the T/F answer itself is binary so option-position bias doesn't apply. - `ai-price-guess-client.tsx` — items only (each item has a free- form numeric answer, no options). - `ai-fillblank-client.tsx` — items only (free-form string answer). - `ai-calcsprint-client.tsx` — items only; calc cycles through problems repeatedly via `index % length`, so shuffling at mount also reshuffles each cycle relative to a vanilla replay. - `ai-chartread-client.tsx` — single question, but 4 multiple- choice options. Shuffle options + recompute correctIndex. `ai-order-client.tsx` already has its own internal shuffle (the gameplay IS to put items in correct order). `ai-matchpairs-client` and `ai-memory-client` randomise via card draw inherently. `ai- budget-client.tsx` and `ai-whatif-client.tsx` have no positional options to shuffle. Validation: - pnpm typecheck → 0 errors - pnpm test → 719/719 Open follow-up: a Sonnet system-prompt nudge in `lib/ai-pipeline/generate.ts` could ask the generator to distribute correctIndex evenly across items at generation time (belt-and-braces with the client shuffle). Sprint H polish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d45feb7 commit 826fe59

6 files changed

Lines changed: 68 additions & 9 deletions

File tree

components/games/ai-calcsprint-client.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { z } from "zod";
55
import type { CalcSpecSchema } from "@/lib/ai-pipeline/types";
66
import { submitScore, type ScoreResponse } from "@/lib/client-api";
77
import { RoundResult } from "@/components/games/round-result";
8+
import { shuffle } from "@/lib/shuffle";
89
import type { Dict } from "@/lib/i18n";
910

1011
type Spec = z.infer<typeof CalcSpecSchema>;
@@ -25,6 +26,11 @@ export function AiCalcSprintClient({
2526
dict: Dict;
2627
}) {
2728
const t = dict.ai;
29+
// G-37 — randomise problem order so a replay shows different
30+
// problems first. Calc-sprint cycles through items repeatedly
31+
// (`index % length`), so shuffling once at mount also reshuffles
32+
// each lap relative to a vanilla replay.
33+
const [shuffledItems] = useState(() => shuffle([...spec.items]));
2834
const [index, setIndex] = useState(0);
2935
const [input, setInput] = useState("");
3036
const [correctCount, setCorrectCount] = useState(0);
@@ -69,7 +75,7 @@ export function AiCalcSprintClient({
6975
return () => clearInterval(id);
7076
}, [done, submit, spec.xpPerCorrect]);
7177

72-
const current = spec.items[index % spec.items.length];
78+
const current = shuffledItems[index % shuffledItems.length];
7379

7480
function check() {
7581
const n = Number(input.replace(",", "."));

components/games/ai-chartread-client.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { z } from "zod";
55
import type { ChartReadSpecSchema } from "@/lib/ai-pipeline/types";
66
import { submitScore, type ScoreResponse } from "@/lib/client-api";
77
import { RoundResult } from "@/components/games/round-result";
8+
import { shuffle } from "@/lib/shuffle";
89
import type { Dict } from "@/lib/i18n";
910

1011
type Spec = z.infer<typeof ChartReadSpecSchema>;
@@ -96,14 +97,26 @@ export function AiChartReadClient({
9697
dict: Dict;
9798
}) {
9899
const t = dict.ai;
100+
// G-37 — shuffle the 4 options + recompute correctIndex so the
101+
// answer position varies between sessions. Single question per
102+
// chart-read spec → mount-time shuffle is enough; no per-item
103+
// loop needed.
104+
const [shuffled] = useState(() => {
105+
const correctOption = spec.options[spec.correctIndex];
106+
const shuffledOptions = shuffle([...spec.options]);
107+
return {
108+
options: shuffledOptions,
109+
correctIndex: shuffledOptions.indexOf(correctOption),
110+
};
111+
});
99112
const [chosen, setChosen] = useState<number | null>(null);
100113
const [revealed, setRevealed] = useState(false);
101114
const [done, setDone] = useState(false);
102115
const [submitting, setSubmitting] = useState(false);
103116
const [error, setError] = useState<string | null>(null);
104117
const [result, setResult] = useState<ScoreResponse | null>(null);
105118

106-
const isCorrect = chosen === spec.correctIndex;
119+
const isCorrect = chosen === shuffled.correctIndex;
107120

108121
const submit = useCallback(
109122
async (xp: number) => {
@@ -137,8 +150,8 @@ export function AiChartReadClient({
137150
<div className="card p-5 flex flex-col gap-4">
138151
<p className="font-semibold">{spec.question}</p>
139152
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
140-
{spec.options.map((opt, i) => {
141-
const isAnswer = i === spec.correctIndex;
153+
{shuffled.options.map((opt, i) => {
154+
const isAnswer = i === shuffled.correctIndex;
142155
const isChosen = chosen === i;
143156
const tone = !revealed
144157
? isChosen

components/games/ai-fillblank-client.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { z } from "zod";
55
import type { FillBlankSpecSchema } from "@/lib/ai-pipeline/types";
66
import { submitScore, type ScoreResponse } from "@/lib/client-api";
77
import { RoundResult } from "@/components/games/round-result";
8+
import { shuffle } from "@/lib/shuffle";
89
import type { Dict } from "@/lib/i18n";
910

1011
type Spec = z.infer<typeof FillBlankSpecSchema>;
@@ -26,6 +27,10 @@ export function AiFillBlankClient({
2627
dict: Dict;
2728
}) {
2829
const t = dict.ai;
30+
// G-37 — randomise item order on mount so a replay shows different
31+
// sentences first. Each item's answer is a free-form string so
32+
// there are no options to per-item-shuffle.
33+
const [shuffledItems] = useState(() => shuffle([...spec.items]));
2934
const [index, setIndex] = useState(0);
3035
const [phase, setPhase] = useState<Phase>("playing");
3136
const [input, setInput] = useState("");
@@ -34,8 +39,8 @@ export function AiFillBlankClient({
3439
const [error, setError] = useState<string | null>(null);
3540
const [result, setResult] = useState<ScoreResponse | null>(null);
3641

37-
const total = spec.items.length;
38-
const current = spec.items[index];
42+
const total = shuffledItems.length;
43+
const current = shuffledItems[index];
3944

4045
const submit = useCallback(
4146
async (xp: number) => {

components/games/ai-price-guess-client.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { z } from "zod";
1111
import type { PriceGuessSpecSchema } from "@/lib/ai-pipeline/types";
1212
import { submitScore, type ScoreResponse } from "@/lib/client-api";
1313
import { RoundResult } from "@/components/games/round-result";
14+
import { shuffle } from "@/lib/shuffle";
1415
import type { Dict } from "@/lib/i18n";
1516

1617
type PriceGuessSpec = z.infer<typeof PriceGuessSpecSchema>;
@@ -33,7 +34,11 @@ export function AiPriceGuessClient({
3334
dict: Dict;
3435
}) {
3536
const t = dict.ai;
36-
const items = spec.items;
37+
// G-37 — randomise item order so a replay shows a different
38+
// sequence. Each item's correct answer is a number (no options to
39+
// shuffle), so per-item shuffle isn't needed; only the order.
40+
const [shuffledItems] = useState(() => shuffle([...spec.items]));
41+
const items = shuffledItems;
3742
const total = items.length;
3843
const xpPer = spec.xpPerCorrect;
3944
const xpCap = total * xpPer;

components/games/ai-quiz-client.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { z } from "zod";
55
import type { QuizSpecSchema } from "@/lib/ai-pipeline/types";
66
import { submitScore, type ScoreResponse } from "@/lib/client-api";
77
import { RoundResult } from "@/components/games/round-result";
8+
import { shuffle } from "@/lib/shuffle";
89
import type { Dict } from "@/lib/i18n";
910

1011
type QuizSpec = z.infer<typeof QuizSpecSchema>;
@@ -20,6 +21,28 @@ export function AiQuizClient({
2021
dict: Dict;
2122
}) {
2223
const t = dict.ai;
24+
// G-37 — shuffle option order per item + question order per session.
25+
// The AI generator's correctIndex distribution can be lopsided
26+
// (Claude tends to put the correct option at index 0 disproportion-
27+
// ately often), and the spec is deterministic — replaying the same
28+
// envelope shows the same answer pattern. Shuffling at mount via
29+
// `useState` initializer makes every mount session distinct without
30+
// re-shuffling on every re-render (which would scramble options
31+
// mid-question and confuse the player). Same pattern as the
32+
// evergreen `pickRound` in app/games/finance-quiz/page.tsx.
33+
const [shuffledItems] = useState(() =>
34+
shuffle(
35+
spec.items.map((item) => {
36+
const correctOption = item.options[item.correctIndex];
37+
const shuffledOptions = shuffle([...item.options]);
38+
return {
39+
...item,
40+
options: shuffledOptions,
41+
correctIndex: shuffledOptions.indexOf(correctOption),
42+
};
43+
}),
44+
),
45+
);
2346
const [index, setIndex] = useState(0);
2447
const [phase, setPhase] = useState<Phase>("playing");
2548
const [chosen, setChosen] = useState<number | null>(null);
@@ -28,7 +51,7 @@ export function AiQuizClient({
2851
const [submitError, setSubmitError] = useState<string | null>(null);
2952
const [result, setResult] = useState<ScoreResponse | null>(null);
3053

31-
const items = spec.items;
54+
const items = shuffledItems;
3255
const total = items.length;
3356
const current = items[index];
3457
const xpPer = spec.xpPerCorrect;

components/games/ai-truefalse-client.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { z } from "zod";
55
import type { TrueFalseSpecSchema } from "@/lib/ai-pipeline/types";
66
import { submitScore, type ScoreResponse } from "@/lib/client-api";
77
import { RoundResult } from "@/components/games/round-result";
8+
import { shuffle } from "@/lib/shuffle";
89
import type { Dict } from "@/lib/i18n";
910

1011
type TrueFalseSpec = z.infer<typeof TrueFalseSpecSchema>;
@@ -20,6 +21,12 @@ export function AiTrueFalseClient({
2021
dict: Dict;
2122
}) {
2223
const t = dict.ai;
24+
// G-37 — shuffle statement order per session. The True/False answer
25+
// itself is binary so option-position bias doesn't apply, but
26+
// replaying the same envelope shouldn't show statements in the
27+
// same order (or in PL-source-document order, which Claude tends
28+
// to follow).
29+
const [shuffledItems] = useState(() => shuffle([...spec.items]));
2330
const [index, setIndex] = useState(0);
2431
const [phase, setPhase] = useState<Phase>("playing");
2532
const [chosen, setChosen] = useState<boolean | null>(null);
@@ -28,7 +35,7 @@ export function AiTrueFalseClient({
2835
const [submitError, setSubmitError] = useState<string | null>(null);
2936
const [result, setResult] = useState<ScoreResponse | null>(null);
3037

31-
const items = spec.items;
38+
const items = shuffledItems;
3239
const total = items.length;
3340
const current = items[index];
3441
const xpPer = spec.xpPerCorrect;

0 commit comments

Comments
 (0)