From f2cc64f9e66787bd7414f9e3ad8fd5b3f2a93c09 Mon Sep 17 00:00:00 2001 From: rinaldo stevenazzi Date: Mon, 30 Jun 2025 12:47:56 +0200 Subject: [PATCH 1/3] improve calculation --- actions/game.test.ts | 43 ++++++++++++++ actions/result-actions.ts | 120 ++++++++++++++++++++++++-------------- stores/game.test.ts | 40 ------------- 3 files changed, 120 insertions(+), 83 deletions(-) create mode 100644 actions/game.test.ts delete mode 100644 stores/game.test.ts diff --git a/actions/game.test.ts b/actions/game.test.ts new file mode 100644 index 0000000..8537245 --- /dev/null +++ b/actions/game.test.ts @@ -0,0 +1,43 @@ +import { computeChallengeScore } from "../actions/result-actions"; +import { GameChallengeType, useGameStore } from "@/stores/game"; + +type ChallengeArgs = { + complexity: number; + timerValue: number; // ms + moves: number; +}; + +/* helper to build a minimal, type-safe challenge */ +const make = ({ + complexity, + timerValue, + moves, +}: ChallengeArgs): GameChallengeType => ({ + complexity, + timerValue, + moves, + image: {} as any, + pieces: [], + isVertical: false, +}); + +/* brand-new unit tests for the helper ---------------------------- */ +describe("computeChallengeScore (unit)", () => { + const cases = [ + make({ complexity: 1, timerValue: 5000, moves: 1 }), + make({ complexity: 2, timerValue: 3000, moves: 2 }), + make({ complexity: 3, timerValue: 6000, moves: 3 }), + make({ complexity: 1, timerValue: 8000, moves: 2 }), + make({ complexity: 2, timerValue: 10000, moves: 4 }), + ]; + + cases.forEach((challenge, idx) => { + it(`prints score for challenge #${idx + 1}`, () => { + const score = computeChallengeScore(challenge); + // eslint-disable-next-line no-console + console.log(`Challenge ${idx + 1} score:`, score); + expect(typeof score).toBe("number"); + expect(score).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/actions/result-actions.ts b/actions/result-actions.ts index 9e89e29..8671340 100644 --- a/actions/result-actions.ts +++ b/actions/result-actions.ts @@ -2,52 +2,86 @@ import { useResultStore } from "@/stores/results"; import { GameChallengeType } from "@/stores/game"; +/*──────────────── tunable constants ────────────────*/ +const TIME_PER_PIECE = 2; // ideal seconds per puzzle piece +const TIME_WEIGHT = 0.7; // speed importance (0-1) +const MOVE_WEIGHT = 0.3; // move efficiency (0-1) + +/*──────────────── per-challenge score ────────────────*/ +export function computeChallengeScore({ + complexity, + timerValue, // milliseconds + moves, +}: GameChallengeType): number { + if (complexity <= 0) return 0; + + const elapsedSec = timerValue / 1000; + const idealTime = complexity * TIME_PER_PIECE; + + const timeRatio = Math.min(1, idealTime / Math.max(elapsedSec, 0.001)); + const moveRatio = Math.min(1, complexity / Math.max(moves, 1)); + + const efficiency = + (TIME_WEIGHT * timeRatio + MOVE_WEIGHT * moveRatio) / + (TIME_WEIGHT + MOVE_WEIGHT); + + return Math.round(efficiency * complexity * 100); // scale by difficulty +} + +/*──────────────── aggregated game score ────────────────*/ +export async function getResultScore(): Promise { + const results = useResultStore.getState().getResults(); + if (results.length === 0) return 0; + + return results.reduce((total, ch) => total + computeChallengeScore(ch), 0); +} + /** * Compute the aggregated score for the current game. * Logic is lifted from `useGameStore.actions.getScore`. */ -export async function getResultScore(): Promise { - const results = useResultStore.getState().getResults(); - const MAX_COMPLEXITY = 3; +// export async function getResultScore(): Promise { +// const results = useResultStore.getState().getResults(); +// const MAX_COMPLEXITY = 3; - if (results.length === 0) return 0; +// if (results.length === 0) return 0; - return results.reduce((totalScore: number, challenge: GameChallengeType) => { - const { complexity, timerValue, moves } = challenge; - - // Convert ms → s for finer granularity - const elapsedSec = timerValue / 1000; - - // Tier threshold (middle tier if complexity ≤ half of max) - const midThreshold = Math.ceil(MAX_COMPLEXITY / 2); - - // Ideal benchmarks & base score - let idealTime: number; - const idealMoves = complexity; - let baseScore: number; - - if (complexity === 1) { - idealTime = 1; - baseScore = 100; - } else if (complexity <= midThreshold) { - idealTime = MAX_COMPLEXITY; - baseScore = 150; - } else { - idealTime = MAX_COMPLEXITY * 2; - baseScore = 200; - } - - // Penalties - const timePenalty = Math.max(0, elapsedSec - idealTime); - const extraMoves = Math.max(0, moves - idealMoves); - const movePenalty = extraMoves * 2; - - // Final clamped score for this challenge - const challengeScore = Math.max( - 0, - Math.round(baseScore - timePenalty - movePenalty) - ); - - return totalScore + challengeScore; - }, 0); -} +// return results.reduce((totalScore: number, challenge: GameChallengeType) => { +// const { complexity, timerValue, moves } = challenge; + +// // Convert ms → s for finer granularity +// const elapsedSec = timerValue / 1000; + +// // Tier threshold (middle tier if complexity ≤ half of max) +// const midThreshold = Math.ceil(MAX_COMPLEXITY / 2); + +// // Ideal benchmarks & base score +// let idealTime: number; +// const idealMoves = complexity; +// let baseScore: number; + +// if (complexity === 1) { +// idealTime = 1; +// baseScore = 100; +// } else if (complexity <= midThreshold) { +// idealTime = MAX_COMPLEXITY; +// baseScore = 150; +// } else { +// idealTime = MAX_COMPLEXITY * 2; +// baseScore = 200; +// } + +// // Penalties +// const timePenalty = Math.max(0, elapsedSec - idealTime); +// const extraMoves = Math.max(0, moves - idealMoves); +// const movePenalty = extraMoves * 2; + +// // Final clamped score for this challenge +// const challengeScore = Math.max( +// 0, +// Math.round(baseScore - timePenalty - movePenalty) +// ); + +// return totalScore + challengeScore; +// }, 0); +// } diff --git a/stores/game.test.ts b/stores/game.test.ts deleted file mode 100644 index 322cbe6..0000000 --- a/stores/game.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useGameStore } from "./game"; - -type ChallengeArgs = { - complexity: number; - timerValue: number; // ms - moves: number; -}; - -// helper to build a minimal challenge -const make = ({ complexity, timerValue, moves }: ChallengeArgs) => ({ - completed: true, - complexity, - timerValue, - moves, - image: {} as any, - pieces: [], -}); - -describe("getScore (called with mocked challenges)", () => { - it("runs without touching setState or mocking getScore", () => { - // Five compliant challenges - const challenges = [ - make({ complexity: 1, timerValue: 5000, moves: 1 }), - make({ complexity: 2, timerValue: 3000, moves: 2 }), - make({ complexity: 3, timerValue: 6000, moves: 3 }), - make({ complexity: 1, timerValue: 8000, moves: 2 }), - make({ complexity: 2, timerValue: 10000, moves: 4 }), - ]; - - // --- Inject the challenges WITHOUT setState ------------------- - // get() (inside getScore) will read from this same object. - const state = useGameStore.getState(); - (state as any).challenges = challenges; - - // --- Call the real getScore ----------------------------------- - const score = state.actions.getScore(); - expect(typeof score).toBe("number"); - // no result check requested - }); -}); From 5735da44ee0684d96efd40f0b035e310887eec57 Mon Sep 17 00:00:00 2001 From: rinaldo stevenazzi Date: Sun, 5 Oct 2025 14:35:06 +0200 Subject: [PATCH 2/3] WIP - improve score calculation --- .vscode/settings.json | 3 ++ helpers/__tests__/scores.test.ts | 69 ++++++++++++++++++++++++++++++++ helpers/scores.ts | 44 ++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 helpers/__tests__/scores.test.ts create mode 100644 helpers/scores.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5480842 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kiroAgent.configureMCP": "Disabled" +} \ No newline at end of file diff --git a/helpers/__tests__/scores.test.ts b/helpers/__tests__/scores.test.ts new file mode 100644 index 0000000..34a2f12 --- /dev/null +++ b/helpers/__tests__/scores.test.ts @@ -0,0 +1,69 @@ +import { computeChallengeScore, computeTotalScore } from "@/helpers/scores"; +import type { GameChallengeType } from "@/stores/game"; + +function makeChallenge(partial: Partial): GameChallengeType { + return { + image: {} as any, + pieces: [] as any, + isVertical: false, + complexity: 1, + moves: 0, + timerValue: 0, + ...partial, + }; +} + +describe("scores helper", () => { + describe("computeChallengeScore", () => { + it("uses time as the primary component (ms)", () => { + const fastWithMoves = makeChallenge({ complexity: 2, moves: 5, timerValue: 1500 }); // 1500ms, 3 extra moves + const slowNoMoves = makeChallenge({ complexity: 2, moves: 2, timerValue: 2500 }); // 2500ms, 0 extra moves + + const s1 = computeChallengeScore(fastWithMoves); // 1500 + 3*1000 = 4500 + const s2 = computeChallengeScore(slowNoMoves); // 2500 + 0 = 2500 + + expect(s2).toBe(2500); + expect(s1).toBe(4500); + expect(s2).toBeLessThan(s1); + }); + + it("penalizes only moves beyond complexity (ms)", () => { + const equalMoves = makeChallenge({ complexity: 3, moves: 3, timerValue: 1000 }); + const extraMoves = makeChallenge({ complexity: 3, moves: 5, timerValue: 1000 }); + + expect(computeChallengeScore(equalMoves)).toBe(1000); + // 2 extra moves => +2000ms penalty (1000 each) + expect(computeChallengeScore(extraMoves)).toBe(3000); + }); + + it("fewer moves than complexity reduces score via a capped bonus (ms)", () => { + const slightBonus = makeChallenge({ complexity: 3, moves: 2, timerValue: 2500 }); + // 1 move under complexity => -500ms bonus. + expect(computeChallengeScore(slightBonus)).toBe(2000); + + const largeBonus = makeChallenge({ complexity: 3, moves: 1, timerValue: 2000 }); + // 2 moves under complexity => -1000ms bonus, limited by 50% of elapsed time. + expect(computeChallengeScore(largeBonus)).toBe(1000); + }); + + it("limits bonuses to half of the elapsed time", () => { + const capped = makeChallenge({ complexity: 5, moves: 0, timerValue: 2000 }); + // Raw bonus 5 * 500 = 2500 but capped at 1000 (50% of 2000). + expect(computeChallengeScore(capped)).toBe(1000); + }); + }); + + describe("computeTotalScore", () => { + it("sums per-challenge scores (ms)", () => { + const a = makeChallenge({ complexity: 1, moves: 1, timerValue: 1000 }); // 1000 + const b = makeChallenge({ complexity: 2, moves: 4, timerValue: 1500 }); // 1500 + 2*1000 = 3500 + const c = makeChallenge({ complexity: 3, moves: 2, timerValue: 2200 }); // 2200 - 500 = 1700 + + expect(computeTotalScore([a, b, c])).toBe(6200); + }); + + it("returns 0 for empty results", () => { + expect(computeTotalScore([])).toBe(0); + }); + }); +}); diff --git a/helpers/scores.ts b/helpers/scores.ts new file mode 100644 index 0000000..8654915 --- /dev/null +++ b/helpers/scores.ts @@ -0,0 +1,44 @@ +import type { GameChallengeType } from "@/stores/game"; + +const MAX_COMPLEXITY = 3; +// Adjustment applied per move relative to complexity, in milliseconds. +// Chosen to be larger than the former 0.5s to keep move cost meaningful with ms scoring. +const MOVE_PENALTY_MS = 1000; +const MOVE_BONUS_MS = 500; +const MAX_BONUS_RATIO = 0.5; + +export function computeChallengeScore( + challenge: GameChallengeType, + _maxComplexity: number = MAX_COMPLEXITY +): number { + const { complexity, timerValue, moves } = challenge; + + // Time-first scoring in milliseconds to yield larger numbers (lower is better). + const elapsedMs = Math.max(0, Math.round(timerValue)); + + const moveDelta = moves - complexity; + + let moveAdjustmentMs = 0; + if (moveDelta > 0) { + moveAdjustmentMs = moveDelta * MOVE_PENALTY_MS; + } else if (moveDelta < 0) { + const rawBonus = Math.abs(moveDelta) * MOVE_BONUS_MS; + const maxBonus = Math.round(elapsedMs * MAX_BONUS_RATIO); + moveAdjustmentMs = -Math.min(rawBonus, maxBonus); + } + + // Integer millisecond score that bottoms out at zero. + return Math.max(0, elapsedMs + moveAdjustmentMs); +} + +export function computeTotalScore( + results: GameChallengeType[], + maxComplexity: number = MAX_COMPLEXITY +): number { + if (!results || results.length === 0) return 0; + const total = results.reduce((sum, challenge) => { + return sum + computeChallengeScore(challenge, maxComplexity); + }, 0); + // Integer millisecond total score. + return Math.max(0, Math.round(total)); +} From d07bc6a76426af1704e71f7ea249aeb806a32745 Mon Sep 17 00:00:00 2001 From: Rinaldo Stevenazzi Date: Mon, 6 Oct 2025 22:20:31 +0200 Subject: [PATCH 3/3] Delete .vscode/settings.json --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5480842..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "kiroAgent.configureMCP": "Disabled" -} \ No newline at end of file