Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions actions/game.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
69 changes: 69 additions & 0 deletions helpers/__tests__/scores.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { computeChallengeScore, computeTotalScore } from "@/helpers/scores";
import type { GameChallengeType } from "@/stores/game";

function makeChallenge(partial: Partial<GameChallengeType>): 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);
});
});
});
44 changes: 44 additions & 0 deletions helpers/scores.ts
Original file line number Diff line number Diff line change
@@ -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));
}