From 2713979721c2563bfbab1cc8c0c3fe9a058cd5b8 Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Mon, 27 Jul 2026 17:33:58 +0100 Subject: [PATCH 1/4] add puzzle component --- frontend/components/PuzzleComponent.jsx | 431 ++++++++++++++++++++++-- 1 file changed, 395 insertions(+), 36 deletions(-) diff --git a/frontend/components/PuzzleComponent.jsx b/frontend/components/PuzzleComponent.jsx index 1939714..442cdc8 100644 --- a/frontend/components/PuzzleComponent.jsx +++ b/frontend/components/PuzzleComponent.jsx @@ -1,5 +1,6 @@ "use client"; -import { useState } from "react"; + +import { useState, useEffect, useCallback } from "react"; import { Card, CardContent, @@ -7,63 +8,421 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -// puzzle data to simulate data coming in -const puzzleData = { +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { usePuzzleContract } from "@/hooks/usePuzzleContract"; +import { + CheckCircle2, + XCircle, + Loader2, + Lightbulb, + ChevronRight, + Trophy, + AlertTriangle, + Wallet, + SendHorizonal, + Sparkles, + ArrowRight, +} from "lucide-react"; + +// ─────────────────────────────────────────────────────────────── +// Default puzzle data (used as a fallback / demo) +// ─────────────────────────────────────────────────────────────── +const DEFAULT_PUZZLE = { + id: 0, title: "Simple Math", - puzzleNumber: 3, + puzzleNumber: 1, level: "Easy", levelReward: "StellarHunts Beginner NFT", - puzzle: + question: "How many confirmations are typically recommended for Stellar / Soroban transactions before considering them final?", hint: "Soroban closes finalize within a few seconds on the Stellar network", }; -const PuzzleComponent = () => { +// ─────────────────────────────────────────────────────────────── +// Level display config +// ─────────────────────────────────────────────────────────────── +const LEVEL_CONFIG = { + Easy: { color: "from-emerald-400 to-teal-500", badge: "success" }, + Medium: { color: "from-amber-400 to-orange-500", badge: "warning" }, + Difficult: { color: "from-rose-400 to-red-500", badge: "destructive" }, + Advanced: { color: "from-purple-400 to-violet-500", badge: "default" }, +}; + +// ─────────────────────────────────────────────────────────────── +// Feedback toast component +// ─────────────────────────────────────────────────────────────── +const FeedbackToast = ({ feedback, onClose }) => { + if (!feedback) return null; + + const isCorrect = feedback.correct; + const Icon = isCorrect ? CheckCircle2 : XCircle; + const borderColor = isCorrect + ? "border-emerald-500/50" + : "border-red-500/50"; + const bgGradient = isCorrect + ? "from-emerald-500/10 to-emerald-500/5" + : "from-red-500/10 to-red-500/5"; + const textColor = isCorrect ? "text-emerald-300" : "text-red-300"; + const glowColor = isCorrect + ? "shadow-emerald-500/20" + : "shadow-red-500/20"; + + return ( +
+
+
+ +
+
+

+ {isCorrect ? "Correct!" : "Incorrect"} +

+

{feedback.message}

+
+ {isCorrect && ( + + )} +
+ + {/* Progress bar-like decorative line */} +
+
+ ); +}; + +// ─────────────────────────────────────────────────────────────── +// Progress bar for level completion +// ─────────────────────────────────────────────────────────────── +const LevelProgressBar = ({ current, total }) => { + const percentage = total > 0 ? Math.round((current / total) * 100) : 0; + + return ( +
+
+ + + Level Progress + + + {current} / {total} puzzles + +
+
+ {/* Animated gradient bar */} +
+
+
+
+

{percentage}% complete

+
+ ); +}; + +// ─────────────────────────────────────────────────────────────── +// Main PuzzleComponent +// ─────────────────────────────────────────────────────────────── +const PuzzleComponent = ({ + puzzleData: externalPuzzle, + questionId, + onCorrectAnswer, + levelProgress = { current: 0, total: 5 }, + contractOpts = {}, +}) => { + // Merge external data with fallback defaults + const puzzle = externalPuzzle || DEFAULT_PUZZLE; + const qId = questionId ?? puzzle.id ?? 0; + + // ── Local state ─────────────────────────────────────────── + const [answer, setAnswer] = useState(""); const [showHint, setShowHint] = useState(false); + const [tempHint, setTempHint] = useState(null); // shown while contract hint loads + const [submitted, setSubmitted] = useState(false); + + // ── Contract hook ───────────────────────────────────────── + const { + submitting, + hintLoading, + error: contractError, + feedback, + lastHint, + walletConnected, + walletAddress, + submitAnswer, + requestHint, + clearFeedback, + checkWallet, + } = usePuzzleContract(contractOpts); + + // Check wallet on mount + useEffect(() => { + checkWallet(); + }, [checkWallet]); + + // ── Handlers ────────────────────────────────────────────── + + const handleSubmit = useCallback( + async (e) => { + e?.preventDefault(); + if (!answer.trim()) return; + + setSubmitted(true); + const result = await submitAnswer(qId, answer.trim()); + + if (result?.correct && onCorrectAnswer) { + onCorrectAnswer(qId); + } + }, + [answer, qId, submitAnswer, onCorrectAnswer] + ); + + const handleHintToggle = useCallback(async () => { + if (showHint) { + setShowHint(false); + return; + } + + // If we already fetched the hint, just show it + if (lastHint) { + setShowHint(true); + return; + } + + // Try fetching from the contract; fall back to the puzzle's local hint + try { + const hint = await requestHint(qId); + if (hint) { + setTempHint(hint); + } + } catch { + // Contract call failed — use local hint as fallback + setTempHint(puzzle.hint); + } + setShowHint(true); + }, [showHint, lastHint, requestHint, qId, puzzle.hint]); + + const handleAnswerChange = useCallback( + (e) => { + setAnswer(e.target.value); + if (submitted) { + setSubmitted(false); + clearFeedback(); + } + }, + [submitted, clearFeedback] + ); + + // The hint text to display (contract > local fallback) + const displayHint = lastHint || tempHint || puzzle.hint; + + // ── Visual config ───────────────────────────────────────── + const levelKey = puzzle.level || "Easy"; + const levelStyle = LEVEL_CONFIG[levelKey] || LEVEL_CONFIG.Easy; + const isCorrect = feedback?.correct; - const handleShowHint = () => { - setShowHint((prev) => !prev); - }; return ( - - - - {puzzleData.level} - Puzzle {puzzleData.puzzleNumber}:{" "} - {puzzleData.title} + + {/* Animated gradient orb decoration */} +
+
+ + {/* ── Header ─────────────────────────────────────────── */} + +
+
+ + {puzzle.level} + + + Puzzle {puzzle.puzzleNumber} + +
+ + {/* Wallet indicator */} +
+
+ + {walletConnected + ? `${walletAddress?.slice(0, 4)}…${walletAddress?.slice(-4)}` + : "Wallet disconnected"} + +
+
+ + + {puzzle.title} - - Level Reward: {puzzleData.levelReward} - + + {puzzle.levelReward && ( + + + Level Reward:{" "} + + {puzzle.levelReward} + + + )} + + {/* Progress bar */} + - -
- -
+ + {/* ── Content ────────────────────────────────────────── */} + + {/* Question */} +
+ +

+ {puzzle.question} +

+
+ + {/* ── Input & Submit ───────────────────────────────── */} + +
- + {/* Character count indicator */} + + {answer.length} + +
+ +
+ + + {/* ── Feedback ─────────────────────────────────────── */} +
+ {feedback && ( + + )} + {contractError && !feedback && ( + + + + {contractError} + + + )} +
+ + {/* ── Footer actions ───────────────────────────────── */} +
+ {/* Hint button */} + + {/* Show hint text */} {showHint && ( -

- {puzzleData.hint} -

+
+
+
+ +

+ {hintLoading ? "Loading hint…" : displayHint} +

+
+
+
)} - +
+ + {/* ── Connect wallet CTA ───────────────────────────── */} + {!walletConnected && ( +
+ +

+ Connect your Freighter wallet to submit answers and earn on-chain rewards. +

+
+ )}
); From 4eaa7d5de6fc80706b433e961b58b6673f4e1706 Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Mon, 27 Jul 2026 17:34:08 +0100 Subject: [PATCH 2/4] puzzle file --- frontend/app/globals.css | 14 ++ frontend/hooks/usePuzzleContract.js | 342 ++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 frontend/hooks/usePuzzleContract.js diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b80d1e4..3a9b67e 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -12,6 +12,20 @@ body { } } +@layer utilities { + @keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } + } + .animate-shimmer { + animation: shimmer 2s ease-in-out infinite; + } +} + @layer base { :root { --radius: 0.5rem; diff --git a/frontend/hooks/usePuzzleContract.js b/frontend/hooks/usePuzzleContract.js new file mode 100644 index 0000000..7711d3c --- /dev/null +++ b/frontend/hooks/usePuzzleContract.js @@ -0,0 +1,342 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + SorobanRpc, + TransactionBuilder, + Networks, + nativeToScVal, + scValToNative, + xdr, + Address, +} from "@stellar/stellar-sdk"; +import { + isConnected, + getPublicKey, + signTransaction, +} from "@stellar/freighter-api"; + +/** + * Default RPC endpoint for the Stellar Soroban testnet. + * Override via NEXT_PUBLIC_SOROBAN_RPC_URL env var. + */ +const DEFAULT_RPC_URL = + process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || + "https://soroban-testnet.stellar.org"; + +/** + * The deployed contract address for StellarHunts. + * Must be set via NEXT_PUBLIC_STELLAR_HUNTS_CONTRACT_ID env var. + */ +const DEFAULT_CONTRACT_ID = + process.env.NEXT_PUBLIC_STELLAR_HUNTS_CONTRACT_ID || ""; + +/** + * Polling interval (ms) when waiting for a Soroban transaction to complete. + */ +const POLL_INTERVAL = 1000; + +/** + * Max number of polling attempts before giving up. + */ +const MAX_POLL_ATTEMPTS = 30; + +/** + * Converts a plain string into a Soroban Bytes ScVal (UTF-8 encoded). + * The on-chain contract stores answers/hints as `Bytes`. + */ +function stringToBytesScVal(str) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + return xdr.ScVal.scvBytes(bytes); +} + +/** + * Safely converts a Soroban Bytes ScVal back into a UTF-8 string. + * Returns the original ScVal hex if decoding fails. + */ +function bytesScValToString(scVal) { + try { + const raw = scVal.value(); + if (raw && raw.length) { + const decoder = new TextDecoder("utf-8"); + return decoder.decode(raw); + } + } catch { + // Non-UTF-8 bytes — fall back to hex + if (scVal.value()?.length) { + return `0x${Buffer.from(scVal.value()).toString("hex").slice(0, 32)}…`; + } + } + return ""; +} + +/** + * Polls SorobanRpc.Server#getTransaction until the transaction + * reaches a terminal status (SUCCESS / FAILED). + */ +async function pollTransaction(server, hash, attempts = 0) { + if (attempts >= MAX_POLL_ATTEMPTS) { + throw new Error("Transaction did not finalise in time"); + } + + const result = await server.getTransaction(hash); + + if (result.status === "NOT_FOUND") { + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + return pollTransaction(server, hash, attempts + 1); + } + + if (result.status === "FAILED") { + throw new Error( + result.result?.error?.message || + result.error?.message || + "Transaction failed on chain" + ); + } + + return result; +} + +/** + * usePuzzleContract + * + * React hook that manages all Soroban smart-contract interactions for the + * StellarHunts puzzle game. Requires the Freighter browser extension. + * + * @param {object} opts + * @param {string} opts.rpcUrl – Soroban RPC endpoint (defaults to testnet) + * @param {string} opts.contractId – Deployed contract address (hex string) + * @param {string} opts.networkPassphrase – Stellar network passphrase + * + * @returns {object} + * @prop {boolean} submitting – true while a submit_answer tx is in flight + * @prop {boolean} hintLoading – true while a request_hint tx is in flight + * @prop {string|null} error – last error message + * @prop {object|null} feedback – { correct: boolean, message: string } | null + * @prop {string|null} lastHint – the hint returned from the contract + * @prop {boolean} walletConnected – whether Freighter reports a connection + * @prop {string|null} walletAddress – the user's Stellar public key + * @prop {Function} submitAnswer – async (questionId: number, answer: string) => { correct, message } + * @prop {Function} requestHint – async (questionId: number) => string + * @prop {Function} clearFeedback – reset feedback & error to null + * @prop {Function} checkWallet – refresh wallet connected state + */ +export function usePuzzleContract(opts = {}) { + const { + rpcUrl = DEFAULT_RPC_URL, + contractId = DEFAULT_CONTRACT_ID, + networkPassphrase = Networks.TESTNET, + } = opts; + + // Guard: warn early if no contract ID is configured + if (!contractId && typeof console !== "undefined") { + console.warn( + "[usePuzzleContract] NEXT_PUBLIC_STELLAR_HUNTS_CONTRACT_ID is not set. " + + "Contract calls will fail at runtime." + ); + } + + const [submitting, setSubmitting] = useState(false); + const [hintLoading, setHintLoading] = useState(false); + const [error, setError] = useState(null); + const [feedback, setFeedback] = useState(null); + const [lastHint, setLastHint] = useState(null); + const [walletConnected, setWalletConnected] = useState(false); + const [walletAddress, setWalletAddress] = useState(null); + + // ---- Wallet helpers ------------------------------------------------ + + const checkWallet = useCallback(async () => { + try { + const connected = await isConnected(); + setWalletConnected(connected); + if (connected) { + const pk = await getPublicKey(); + setWalletAddress(pk); + return pk; + } + setWalletAddress(null); + return null; + } catch { + setWalletConnected(false); + setWalletAddress(null); + return null; + } + }, []); + + const ensureWallet = useCallback(async () => { + const pk = await checkWallet(); + if (!pk) { + throw new Error( + "Freighter wallet is not connected. Please install & connect Freighter." + ); + } + return pk; + }, [checkWallet]); + + // ---- Soroban helpers ----------------------------------------------- + + const buildServer = useCallback(() => { + if (!contractId) { + throw new Error( + "Contract ID is not configured. Set NEXT_PUBLIC_STELLAR_HUNTS_CONTRACT_ID." + ); + } + return { server: new SorobanRpc.Server(rpcUrl), contractId }; + }, [rpcUrl, contractId]); + + /** + * Signs & sends a Soroban transaction via Freighter, then polls for + * the result and returns the raw SorobanRpc.GetTransaction response. + */ + const invokeContract = useCallback( + async (methodName, scValArgs) => { + const publicKey = await ensureWallet(); + const { server, contractId: cId } = buildServer(); + + const sourceAccount = await server.getAccount(publicKey); + const contract = new SorobanRpc.Contract(cId); + + const tx = new TransactionBuilder(sourceAccount, { + fee: "100", + networkPassphrase, + }) + .addOperation(contract.call(methodName, ...scValArgs)) + .setTimeout(30) + .build(); + + // 1) Prepare (simulate & populate footprint / auth) + const preparedTx = await server.prepareTransaction(tx); + + // 2) Sign with Freighter + const signedXdr = await signTransaction(preparedTx.toXDR(), { + networkPassphrase, + }); + const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + + // 3) Submit + const sendResponse = await server.sendTransaction(signedTx); + if ( + sendResponse.status === "PENDING" || + sendResponse.status === "DUPLICATE" + ) { + // 4) Poll for completion + const result = await pollTransaction(server, sendResponse.hash); + return result; + } + + throw new Error( + sendResponse.error?.message || + `Unexpected send status: ${sendResponse.status}` + ); + }, + [ensureWallet, buildServer, networkPassphrase] + ); + + // ---- Public actions ------------------------------------------------ + + /** + * Submit an answer for a puzzle. + * + * @param {number} questionId – The on-chain question_id (u64) + * @param {string} answer – The plain-text answer + * @returns {{ correct: boolean, message: string }} + */ + const submitAnswer = useCallback( + async (questionId, answer) => { + setSubmitting(true); + setError(null); + setFeedback(null); + + try { + // Build ScVal arguments for the contract's submit_answer fn: + // submit_answer(env, caller: Address, question_id: u64, answer: Bytes) -> bool + const args = [ + Address.fromString(walletAddress || (await ensureWallet())).toScVal(), + nativeToScVal(BigInt(questionId), { type: "u64" }), + stringToBytesScVal(answer), + ]; + + const result = await invokeContract("submit_answer", args); + + // Parse the return value (bool) + const isCorrect = scValToNative(result.returnValue); + + const fb = { + correct: isCorrect, + message: isCorrect + ? "🎉 Correct! Well done!" + : "❌ Incorrect answer. Try again!", + }; + setFeedback(fb); + return fb; + } catch (err) { + const message = err.message || "Transaction failed"; + setError(message); + setFeedback({ correct: false, message }); + return { correct: false, message }; + } finally { + setSubmitting(false); + } + }, + [invokeContract, walletAddress, ensureWallet] + ); + + /** + * Request a hint for a puzzle from the smart contract. + * + * @param {number} questionId + * @returns {string} The hint text + */ + const requestHint = useCallback( + async (questionId) => { + setHintLoading(true); + setError(null); + + try { + // Build ScVal args: + // request_hint(env, caller: Address, question_id: u64) -> Bytes + const args = [ + Address.fromString(walletAddress || (await ensureWallet())).toScVal(), + nativeToScVal(BigInt(questionId), { type: "u64" }), + ]; + + const result = await invokeContract("request_hint", args); + + // Return value is Bytes — decode to string + const hint = bytesScValToString(result.returnValue); + setLastHint(hint); + return hint; + } catch (err) { + const message = err.message || "Failed to fetch hint"; + setError(message); + throw err; + } finally { + setHintLoading(false); + } + }, + [invokeContract, walletAddress, ensureWallet] + ); + + const clearFeedback = useCallback(() => { + setFeedback(null); + setError(null); + }, []); + + return { + submitting, + hintLoading, + error, + feedback, + lastHint, + walletConnected, + walletAddress, + submitAnswer, + requestHint, + clearFeedback, + checkWallet, + }; +} + +export default usePuzzleContract; From 7f9a9e97015277879a86faf39699667735696b54 Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Mon, 27 Jul 2026 19:38:57 +0100 Subject: [PATCH 3/4] arbitary --- backend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/package.json b/backend/package.json index 870a235..77d5271 100644 --- a/backend/package.json +++ b/backend/package.json @@ -78,6 +78,7 @@ "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", "@types/uuid": "^10.0.0", + "fast-check": "^3.23.2", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^8.0.0", From 454760fbac29c898e6314e55c5d0d32e6a230e4e Mon Sep 17 00:00:00 2001 From: Qoder-Undefined Date: Mon, 27 Jul 2026 19:54:24 +0100 Subject: [PATCH 4/4] analytics service --- .../analytics/analytics.service.prop.spec.ts | 289 ++++++++++++ .../multiplayer-queue.service.prop.spec.ts | 418 ++++++++++++++++++ 2 files changed, 707 insertions(+) create mode 100644 backend/src/analytics/analytics.service.prop.spec.ts create mode 100644 backend/src/multiplayer-queue/multiplayer-queue.service.prop.spec.ts diff --git a/backend/src/analytics/analytics.service.prop.spec.ts b/backend/src/analytics/analytics.service.prop.spec.ts new file mode 100644 index 0000000..1706617 --- /dev/null +++ b/backend/src/analytics/analytics.service.prop.spec.ts @@ -0,0 +1,289 @@ +import * as fc from 'fast-check'; +import { AnalyticsService } from './analytics.service'; + +/** + * Property-based tests for AnalyticsService. + * + * Instead of hand-picked sample inputs, these tests exercise the aggregation + * logic with thousands of randomly generated solve records, then verify that + * mathematical invariants hold regardless of the specific values. + * + * Because AnalyticsService accepts an optional ConfigService (no Redis when + * omitted), we instantiate it directly without NestJS DI inside each property + * run — this keeps the test fast and avoids state bleed between iterations. + */ + +// --------------------------------------------------------------------------- +// Arbitraries (random-value generators) +// --------------------------------------------------------------------------- + +/** A single solve event submitted to the service. */ +const solveRecordArb = fc.record({ + userId: fc.uuid(), + puzzleId: fc.uuid(), + solveTime: fc.integer({ min: 1, max: 7200 }), // 1 s … 2 h +}); + +/** A batch of solves — the core random input to our properties. */ +const solveBatchArb = fc.array(solveRecordArb, { minLength: 0, maxLength: 50 }); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a pristine service for each property invocation. */ +function freshService(): AnalyticsService { + return new AnalyticsService(); +} + +/** Count solves per puzzle across a record batch. */ +function countByPuzzle( + records: ReadonlyArray<{ puzzleId: string }>, +): Map { + const counts = new Map(); + for (const r of records) { + counts.set(r.puzzleId, (counts.get(r.puzzleId) ?? 0) + 1); + } + return counts; +} + +/** Sum solveTime per puzzle across a record batch. */ +function sumTimeByPuzzle( + records: ReadonlyArray<{ puzzleId: string; solveTime: number }>, +): Map { + const sums = new Map(); + for (const r of records) { + sums.set(r.puzzleId, (sums.get(r.puzzleId) ?? 0) + r.solveTime); + } + return sums; +} + +/** Count solves per (user, puzzle) pair. */ +function countByUserAndPuzzle( + records: ReadonlyArray<{ userId: string; puzzleId: string }>, +): Map { + const counts = new Map(); + for (const r of records) { + const key = `${r.userId}::${r.puzzleId}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; +} + +/** Sum solveTime per (user, puzzle) pair. */ +function sumTimeByUserAndPuzzle( + records: ReadonlyArray<{ userId: string; puzzleId: string; solveTime: number }>, +): Map { + const sums = new Map(); + for (const r of records) { + const key = `${r.userId}::${r.puzzleId}`; + sums.set(key, (sums.get(key) ?? 0) + r.solveTime); + } + return sums; +} + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +describe('AnalyticsService — property-based', () => { + // ── Aggregation: solveCount ────────────────────────────────────────── + it('solveCount equals the number of records per puzzle', async () => { + await fc.assert( + fc.asyncProperty(solveBatchArb, async (records) => { + const svc = freshService(); + for (const r of records) { + await svc.recordPuzzleSolveAsync(r.userId, r.puzzleId, r.solveTime); + } + const expected = countByPuzzle(records); + const solved = await svc.getMostSolvedPuzzlesAsync(); + + expect(solved.length).toBe(expected.size); + for (const { puzzleId, solveCount } of solved) { + expect(solveCount).toBe(expected.get(puzzleId)!); + } + }), + ); + }); + + // ── Aggregation: solveCount via sync API ──────────────────────────── + it('sync recordPuzzleSolve agrees with async variant on solveCount', async () => { + await fc.assert( + fc.asyncProperty(solveBatchArb, async (records) => { + const svc = freshService(); + for (const r of records) { + svc.recordPuzzleSolve(r.userId, r.puzzleId, r.solveTime); + } + const expected = countByPuzzle(records); + const solved = await svc.getMostSolvedPuzzlesAsync(); + + expect(solved.length).toBe(expected.size); + for (const { puzzleId, solveCount } of solved) { + expect(solveCount).toBe(expected.get(puzzleId)!); + } + }), + ); + }); + + // ── Aggregation: totalSolveTime ───────────────────────────────────── + it('totalSolveTime equals the sum of solveTime per puzzle', async () => { + await fc.assert( + fc.asyncProperty(solveBatchArb, async (records) => { + const svc = freshService(); + for (const r of records) { + await svc.recordPuzzleSolveAsync(r.userId, r.puzzleId, r.solveTime); + } + const expectedSums = sumTimeByPuzzle(records); + + // Verify average solve time implies correct total + for (const [puzzleId, totalTime] of expectedSums) { + const count = countByPuzzle(records).get(puzzleId)!; + const avg = await svc.getAverageSolveTimeAsync(puzzleId); + expect(avg).toBe(count > 0 ? totalTime / count : 0); + } + }), + ); + }); + + // ── Unknown puzzle returns 0 average ───────────────────────────────── + it('average solve time for an unknown puzzle is 0', async () => { + await fc.assert( + fc.asyncProperty( + fc.uuid(), // puzzle id that was never recorded + fc.array(solveRecordArb, { maxLength: 10 }), + async (unknownId, records) => { + const svc = freshService(); + for (const r of records) { + await svc.recordPuzzleSolveAsync(r.userId, r.puzzleId, r.solveTime); + } + await expect(svc.getAverageSolveTimeAsync(unknownId)).resolves.toBe(0); + }, + ), + ); + }); + + // ── Ordering invariant ────────────────────────────────────────────── + it('getMostSolvedPuzzles returns results sorted descending by solveCount', async () => { + await fc.assert( + fc.asyncProperty(solveBatchArb, async (records) => { + const svc = freshService(); + for (const r of records) { + await svc.recordPuzzleSolveAsync(r.userId, r.puzzleId, r.solveTime); + } + const solved = await svc.getMostSolvedPuzzlesAsync(); + for (let i = 1; i < solved.length; i++) { + expect(solved[i - 1].solveCount).toBeGreaterThanOrEqual( + solved[i].solveCount, + ); + } + }), + ); + }); + + // ── Limit invariant ───────────────────────────────────────────────── + it('getMostSolvedPuzzles with limit returns at most that many entries', async () => { + await fc.assert( + fc.asyncProperty( + solveBatchArb, + fc.integer({ min: 0, max: 20 }), + async (records, limit) => { + const svc = freshService(); + for (const r of records) { + await svc.recordPuzzleSolveAsync(r.userId, r.puzzleId, r.solveTime); + } + const solved = await svc.getMostSolvedPuzzlesAsync(limit); + expect(solved.length).toBeLessThanOrEqual(limit); + expect(solved.length).toBeLessThanOrEqual( + countByPuzzle(records).size, + ); + }, + ), + ); + }); + + // ── User-level engagement mirrors puzzle-level aggregates ─────────── + it('user puzzle stats reflect per-user aggregates', async () => { + await fc.assert( + fc.asyncProperty(solveBatchArb, async (records) => { + const svc = freshService(); + for (const r of records) { + await svc.recordPuzzleSolveAsync(r.userId, r.puzzleId, r.solveTime); + } + + const userPuzzleCounts = countByUserAndPuzzle(records); + const userPuzzleSums = sumTimeByUserAndPuzzle(records); + + // Check every unique user + const userIds = [...new Set(records.map((r) => r.userId))]; + for (const uid of userIds) { + const history = await svc.getUserPuzzleStatsAsync(uid); + for (const [puzzleId, engagement] of history) { + const key = `${uid}::${puzzleId}`; + expect(engagement.solveCount).toBe(userPuzzleCounts.get(key)!); + expect(engagement.totalSolveTime).toBe(userPuzzleSums.get(key)!); + expect(engagement.attempts).toBe(engagement.solveCount); // attempts === solveCount in this service + if (engagement.solveCount > 0) { + expect(engagement.lastSolved).toBeInstanceOf(Date); + } + } + } + }), + ); + }); + + // ── Empty records produce empty results ──────────────────────────── + it('no recordings yields empty puzzle list and zero averages', async () => { + const svc = freshService(); + const solved = await svc.getMostSolvedPuzzlesAsync(); + expect(solved).toEqual([]); + await expect(svc.getAverageSolveTimeAsync('any')).resolves.toBe(0); + const userStats = await svc.getUserPuzzleStatsAsync('nobody'); + expect(userStats.size).toBe(0); + }); + + // ── Aggregation is additive (prefix property) ─────────────────────── + it('aggregation after concatenating two batches equals the sum of their parts', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(solveRecordArb, { maxLength: 20 }), + fc.array(solveRecordArb, { maxLength: 20 }), + async (batchA, batchB) => { + const combined = [...batchA, ...batchB]; + + // Service that receives both batches + const svcCombined = freshService(); + for (const r of combined) { + await svc.recordPuzzleSolveAsync(r.userId, r.puzzleId, r.solveTime); + } + const solvedCombined = await svcCombined.getMostSolvedPuzzlesAsync(); + + // Expected counts + const expected = countByPuzzle(combined); + expect(solvedCombined.length).toBe(expected.size); + for (const { puzzleId, solveCount } of solvedCombined) { + expect(solveCount).toBe(expected.get(puzzleId)!); + } + }, + ), + ); + }); + + // ── No side-effects from read operations ──────────────────────────── + it('read operations do not mutate recorded state', async () => { + const svc = freshService(); + // Record a known solve + await svc.recordPuzzleSolveAsync('user-read-test', 'puzzle-read-test', 100); + + const before = await svc.getMostSolvedPuzzlesAsync(); + + // Perform several reads + for (let i = 0; i < 5; i++) { + await svc.getMostSolvedPuzzlesAsync(); + await svc.getAverageSolveTimeAsync('puzzle-read-test'); + await svc.getUserPuzzleStatsAsync('user-read-test'); + } + + const after = await svc.getMostSolvedPuzzlesAsync(); + expect(after).toEqual(before); + }); +}); diff --git a/backend/src/multiplayer-queue/multiplayer-queue.service.prop.spec.ts b/backend/src/multiplayer-queue/multiplayer-queue.service.prop.spec.ts new file mode 100644 index 0000000..41f686e --- /dev/null +++ b/backend/src/multiplayer-queue/multiplayer-queue.service.prop.spec.ts @@ -0,0 +1,418 @@ +import * as fc from 'fast-check'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { MultiplayerQueueService } from './multiplayer-queue.service'; +import { Queue, QueueStatus, SkillLevel } from './entities/queue.entity'; +import { Match } from './entities/match.entity'; +import { jest } from '@jest/globals'; + +// --------------------------------------------------------------------------- +// Arbitraries (random-value generators) +// --------------------------------------------------------------------------- + +/** A UUID string (v4-like). */ +const uuidArb = fc.uuid(); + +/** Non-empty display name. */ +const usernameArb = fc.string({ minLength: 1, maxLength: 20 }); + +/** One of the four skill-level enum values. */ +const skillLevelArb = fc.constantFrom( + SkillLevel.BEGINNER, + SkillLevel.INTERMEDIATE, + SkillLevel.ADVANCED, + SkillLevel.EXPERT, +); + +/** A game-mode label. */ +const gameModeArb = fc.constantFrom('classic', 'blitz', 'survival'); + +/** Wait time in seconds (0 … 600). */ +const waitTimeArb = fc.integer({ min: 0, max: 600 }); + +/** A single player (Queue entity shape) for testing grouping/compatibility. */ +const queuePlayerArb: fc.Arbitrary = fc.record({ + id: uuidArb, + userId: uuidArb, + username: usernameArb, + status: fc.constant(QueueStatus.WAITING), + skillLevel: skillLevelArb, + gameMode: gameModeArb, + waitTime: waitTimeArb, + matchId: fc.constant(null), + preferences: fc.record({ + maxWaitTime: fc.option(fc.integer({ min: 30, max: 1800 }), { nil: undefined }), + preferredOpponents: fc.option(fc.array(uuidArb, { maxLength: 5 }), { nil: undefined }), + avoidOpponents: fc.option(fc.array(uuidArb, { maxLength: 5 }), { nil: undefined }), + }), + createdAt: fc.date({ min: new Date(0), max: new Date() }), + matchedAt: fc.constant(null), + leftAt: fc.constant(null), +} as unknown as fc.Record); + +/** A batch of players in the queue. */ +const queuePlayerBatchArb = fc.array(queuePlayerArb, { minLength: 0, maxLength: 30 }); + +/** A DTO for joining the queue (valid inputs). */ +const joinQueueDtoArb = fc.record({ + userId: uuidArb, + username: usernameArb, + skillLevel: skillLevelArb, + gameMode: fc.option(gameModeArb, { nil: undefined }), + maxWaitTime: fc.option(fc.integer({ min: 30, max: 1800 }), { nil: undefined }), + preferredOpponents: fc.option(fc.array(uuidArb, { maxLength: 3 }), { nil: undefined }), + avoidOpponents: fc.option(fc.array(uuidArb, { maxLength: 3 }), { nil: undefined }), +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockRepos() { + return { + queueRepository: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + matchRepository: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + }, + }; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('MultiplayerQueueService — property-based', () => { + // ── groupPlayersForMatching ───────────────────────────────────────── + describe('groupPlayersForMatching', () => { + /** Access the private method. */ + function groupPlayers( + service: MultiplayerQueueService, + players: Queue[], + ): Queue[][] { + return (service as any).groupPlayersForMatching(players); + } + + it('every input player appears in at least one group', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property(queuePlayerBatchArb, (players) => { + const groups = groupPlayers(service, players); + const groupedIds = new Set(groups.flat().map((p) => p.id)); + for (const p of players) { + expect(groupedIds.has(p.id)).toBe(true); + } + }), + ); + + module.close(); + }); + + it('players in a same-mode-skill group share gameMode and skillLevel', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property(queuePlayerBatchArb, (players) => { + const groups = groupPlayers(service, players); + for (const group of groups) { + // Skip cross-skill groups (key starts with "cross-skill-") + // and single-player groups (no meaningful check) + if (group.length < 2) continue; + // Identify if this is a cross-skill group + const isCrossSkill = group.some((p) => p.waitTime > 120); + if (isCrossSkill && group.some((p) => p.waitTime <= 120)) { + // Mixed — this is a cross-skill group, skip the strict check + continue; + } + // Regular group: all must share gameMode and skillLevel + const mode = group[0].gameMode; + const level = group[0].skillLevel; + for (const p of group) { + expect(p.gameMode).toBe(mode); + expect(p.skillLevel).toBe(level); + } + } + }), + ); + + module.close(); + }); + + it('cross-skill groups only contain long-waiting players (waitTime > 120)', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property(queuePlayerBatchArb, (players) => { + const groups = groupPlayers(service, players); + for (const group of groups) { + // A cross-skill group has members whose wait times straddle 120 AND + // the group key would be "cross-skill-…" — we approximate by checking + // whether the group contains any long-waiting AND any short-waiting. + const hasLong = group.some((p) => p.waitTime > 120); + const hasShort = group.some((p) => p.waitTime <= 120); + if (hasLong && hasShort) { + // This is a cross-skill bucket — every member must wait > 120 + // Actually it could include both long and short waiters in the + // cross-skill group. Let me check the implementation: + // `longWaitingPlayers.filter(p => p.waitTime > 120)` — so only long + // waiters go into the cross-skill group. + // But the cross-skill group OVERWRITES any existing group with the + // same key, so some players might appear twice (once in their + // skill group, once in cross-skill). That's fine. + for (const p of group) { + expect(p.waitTime).toBeGreaterThan(120); + } + } + } + }), + ); + + module.close(); + }); + + it('no cross-skill group created when fewer than 2 long-waiting players exist', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property(queuePlayerBatchArb, (players) => { + const groups = groupPlayers(service, players); + const longWaitCount = players.filter((p) => p.waitTime > 120).length; + // Cross-skill groups have more than 2 members but mix wait times + // We verify no group has > 2 long-wait-time members when there aren't enough + if (longWaitCount < 2) { + for (const group of groups) { + const longInGroup = group.filter((p) => p.waitTime > 120).length; + expect(longInGroup).toBeLessThan(2); + } + } + }), + ); + + module.close(); + }); + }); + + // ── checkPlayerCompatibility ──────────────────────────────────────── + describe('checkPlayerCompatibility', () => { + function checkCompatibility( + service: MultiplayerQueueService, + players: Queue[], + ): boolean { + return (service as any).checkPlayerCompatibility(players); + } + + it('is symmetric with respect to avoidOpponents', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property( + queuePlayerArb, + queuePlayerArb, + (p1, p2) => { + // Ensure different IDs + const a: Queue = { ...p1, id: 'p1', userId: 'u1' }; + const b: Queue = { ...p2, id: 'p2', userId: 'u2' }; + const resultAB = checkCompatibility(service, [a, b]); + const resultBA = checkCompatibility(service, [b, a]); + expect(resultAB).toBe(resultBA); + }, + ), + ); + + module.close(); + }); + + it('returns true when neither player has avoidOpponents', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property( + queuePlayerArb, + queuePlayerArb, + (p1, p2) => { + const a: Queue = { ...p1, id: 'p1', userId: 'u1', preferences: { ...p1.preferences, avoidOpponents: undefined } }; + const b: Queue = { ...p2, id: 'p2', userId: 'u2', preferences: { ...p2.preferences, avoidOpponents: undefined } }; + expect(checkCompatibility(service, [a, b])).toBe(true); + }, + ), + ); + + module.close(); + }); + + it('returns false when one player avoids the other', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property( + queuePlayerArb, + queuePlayerArb, + (p1, p2) => { + const a: Queue = { + ...p1, id: 'p1', userId: 'u1', + preferences: { ...p1.preferences, avoidOpponents: ['u2'] }, + }; + const b: Queue = { + ...p2, id: 'p2', userId: 'u2', + preferences: { ...p2.preferences, avoidOpponents: undefined }, + }; + expect(checkCompatibility(service, [a, b])).toBe(false); + }, + ), + ); + + module.close(); + }); + + it('single player is always compatible with themselves', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property(queuePlayerArb, (player) => { + expect(checkCompatibility(service, [player])).toBe(true); + }), + ); + + module.close(); + }); + }); + + // ── getQueueStats ─────────────────────────────────────────────────── + describe('getQueueStats', () => { + it('aggregations are internally consistent', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + skillLevel: skillLevelArb, + gameMode: gameModeArb, + createdAt: fc.date({ min: new Date(Date.now() - 86400000), max: new Date() }), + }), + { minLength: 0, maxLength: 20 }, + ), + fc.integer({ min: 0, max: 100 }), + async (waitingEntries, matchesToday) => { + const { mocks, module } = await buildModule(); + + const now = Date.now(); + const entriesWithWait = waitingEntries.map((e) => ({ + ...e, + createdAt: new Date(now - Math.floor(Math.random() * 60000)), + })); + + mocks.queueRepository.find.mockResolvedValue(entriesWithWait); + mocks.matchRepository.count.mockResolvedValue(matchesToday); + + const service = module.get(MultiplayerQueueService); + const stats = await service.getQueueStats(); + + // totalInQueue = number of waiting entries + expect(stats.totalInQueue).toBe(waitingEntries.length); + + // totalInQueue = sum of bySkillLevel + const skillSum = Object.values(stats.bySkillLevel).reduce((a, b) => a + b, 0); + expect(skillSum).toBe(waitingEntries.length); + + // totalInQueue = sum of byGameMode + const modeSum = Object.values(stats.byGameMode).reduce((a, b) => a + b, 0); + expect(modeSum).toBe(waitingEntries.length); + + // Non-negative values + expect(stats.averageWaitTime).toBeGreaterThanOrEqual(0); + expect(stats.longestWaitTime).toBeGreaterThanOrEqual(0); + expect(stats.matchesToday).toBe(matchesToday); + + // averageWaitTime and longestWaitTime consistency + if (waitingEntries.length > 0) { + expect(stats.averageWaitTime).toBeLessThanOrEqual(stats.longestWaitTime); + } else { + expect(stats.averageWaitTime).toBe(0); + expect(stats.longestWaitTime).toBe(0); + } + + module.close(); + }, + ), + ); + }); + }); + + // ── mapToQueueStatusDto ───────────────────────────────────────────── + describe('mapToQueueStatusDto', () => { + function mapDto( + service: MultiplayerQueueService, + queue: Queue, + ): unknown { + return (service as any).mapToQueueStatusDto(queue); + } + + it('preserves all fields through the mapping', async () => { + const { mocks, module } = await buildModule(); + const service = module.get(MultiplayerQueueService); + + await fc.assert( + fc.property(queuePlayerArb, (player) => { + const dto: any = mapDto(service, player); + expect(dto.id).toBe(player.id); + expect(dto.userId).toBe(player.userId); + expect(dto.username).toBe(player.username); + expect(dto.status).toBe(player.status); + expect(dto.skillLevel).toBe(player.skillLevel); + expect(dto.gameMode).toBe(player.gameMode); + expect(dto.waitTime).toBe(player.waitTime); + expect(dto.matchId).toBe(player.matchId); + // Date fields should be preserved + expect(dto.createdAt).toEqual(player.createdAt); + expect(dto.matchedAt).toEqual(player.matchedAt); + }), + ); + + module.close(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Module factory (avoids duplicating the bootstrapping in every property) +// --------------------------------------------------------------------------- + +async function buildModule(): Promise<{ + mocks: ReturnType; + module: TestingModule; +}> { + const mocks = createMockRepos(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MultiplayerQueueService, + { + provide: getRepositoryToken(Queue), + useValue: mocks.queueRepository, + }, + { + provide: getRepositoryToken(Match), + useValue: mocks.matchRepository, + }, + ], + }).compile(); + + return { mocks, module }; +}