From c37a570836138a3bc98c07befbbac20eaa4c942c Mon Sep 17 00:00:00 2001 From: Arnaud Scheffler Date: Thu, 13 Mar 2025 10:26:23 +0100 Subject: [PATCH] feat: first MVP --- .dockerignore | 23 +++++++ Dockerfile | 19 +++++ package-lock.json | 31 +++++++-- package.json | 9 +-- src/lib/Board.svelte | 79 +++++++++++++++++++++ src/lib/Cell.svelte | 14 ++++ src/lib/GameCommands.svelte | 22 ++++++ src/lib/SumIndicator.svelte | 9 +++ src/lib/ValueSelector.svelte | 23 +++++++ src/lib/core/board.test.ts | 58 ++++++++++++++++ src/lib/core/board.ts | 130 +++++++++++++++++++++++++++++++++++ src/lib/index.ts | 1 - src/routes/+page.svelte | 9 ++- svelte.config.js | 5 +- 14 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 src/lib/Board.svelte create mode 100644 src/lib/Cell.svelte create mode 100644 src/lib/GameCommands.svelte create mode 100644 src/lib/SumIndicator.svelte create mode 100644 src/lib/ValueSelector.svelte create mode 100644 src/lib/core/board.test.ts create mode 100644 src/lib/core/board.ts delete mode 100644 src/lib/index.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..15dade8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:18.19-alpine AS builder +WORKDIR /app-builder + +COPY ./package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.25.1 +WORKDIR /app +ARG VERSION=next +# NGINX redirect and cache control +RUN sed -i '8 a \ \ \ \ \ \ \ \ try_files $uri $uri /index.html;' /etc/nginx/conf.d/default.conf +# RUN sed -i "10 a \ \ \ \ \ \ \ \ location /env.js { add_header Cache-Control 'no-store'; add_header Cache-Control 'no-cache'; expires 0;}" /etc/nginx/conf.d/default.conf +# https://pumpingco.de/blog/environment-variables-angular-docker/ +# RUN echo -n 'envsubst < /usr/share/nginx/html/env.template.js > /usr/share/nginx/html/env.js\nrm /usr/share/nginx/html/env.template.js' > /docker-entrypoint.d/100-set-env.sh && chmod +x /docker-entrypoint.d/100-set-env.sh + +COPY --from=builder /app-builder/build /usr/share/nginx/html diff --git a/package-lock.json b/package-lock.json index 31e4f76..e999dce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", "globals": "^16.0.0", + "js-confetti": "^0.12.0", "jsdom": "^26.0.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", @@ -3411,6 +3412,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-confetti": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/js-confetti/-/js-confetti-0.12.0.tgz", + "integrity": "sha512-1R0Akxn3Zn82pMqW65N1V2NwKkZJ75bvBN/VAb36Ya0YHwbaSiAJZVRr/19HBxH/O8x2x01UFAbYI18VqlDN6g==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3888,6 +3896,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4115,13 +4136,15 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" diff --git a/package.json b/package.json index 75ed387..9837e31 100644 --- a/package.json +++ b/package.json @@ -24,19 +24,20 @@ "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/svelte": "^5.2.4", - "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", + "eslint": "^9.18.0", "globals": "^16.0.0", + "js-confetti": "^0.12.0", "jsdom": "^26.0.0", - "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", - "svelte": "^5.0.0", + "prettier": "^3.4.2", "svelte-check": "^4.0.0", + "svelte": "^5.0.0", "tailwindcss": "^4.0.0", - "typescript": "^5.0.0", "typescript-eslint": "^8.20.0", + "typescript": "^5.0.0", "vite": "^6.0.0", "vitest": "^3.0.0" } diff --git a/src/lib/Board.svelte b/src/lib/Board.svelte new file mode 100644 index 0000000..f64ab48 --- /dev/null +++ b/src/lib/Board.svelte @@ -0,0 +1,79 @@ + + + +

STATUS: {status}

+ +
+ {#each board.grid as row, i (row)} + {#each row as cell, j} + + {/each} + + {/each} + {#each board.verticalSum as s} + + {/each} +
+ + diff --git a/src/lib/Cell.svelte b/src/lib/Cell.svelte new file mode 100644 index 0000000..4903467 --- /dev/null +++ b/src/lib/Cell.svelte @@ -0,0 +1,14 @@ + + + diff --git a/src/lib/GameCommands.svelte b/src/lib/GameCommands.svelte new file mode 100644 index 0000000..60181ad --- /dev/null +++ b/src/lib/GameCommands.svelte @@ -0,0 +1,22 @@ + + +
+ + + + +
diff --git a/src/lib/SumIndicator.svelte b/src/lib/SumIndicator.svelte new file mode 100644 index 0000000..7f5a7e1 --- /dev/null +++ b/src/lib/SumIndicator.svelte @@ -0,0 +1,9 @@ + + +
+
+ {value} +
+
diff --git a/src/lib/ValueSelector.svelte b/src/lib/ValueSelector.svelte new file mode 100644 index 0000000..6584f21 --- /dev/null +++ b/src/lib/ValueSelector.svelte @@ -0,0 +1,23 @@ + + +
+ {#each numbers as number (number)} + + {/each} + +
diff --git a/src/lib/core/board.test.ts b/src/lib/core/board.test.ts new file mode 100644 index 0000000..7292247 --- /dev/null +++ b/src/lib/core/board.test.ts @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom/vitest'; +import { describe, expect, test } from 'vitest'; +import { Board, Difficulty } from './board'; + +describe('Board', () => { + test('shape', () => { + const board = new Board(); + + expect(board.grid.length).toBe(3); + expect(board.grid[0].length).toBe(3); + expect(board.horizontalSum.length).toBe(3); + expect(board.verticalSum.length).toBe(3); + }); + + test('isComplete', () => { + const board = new Board(); + expect(board.isComplete()).toBe(true); + + // remove one number + board.setByPos(3); + expect(board.isComplete()).toBe(false); + }); + + test('isValid', () => { + const board = new Board(); + expect(board.isValid()).toBe(true); + }); + + test('isValid ko', () => { + const board = new Board(); + board.horizontalSum[1] = -1; + expect(board.isValid()).toBe(false); + }); + + test('setByPos', () => { + const board = new Board(); + // set board from 9 to 1 + for (let index = 0; index < 9; index++) { + board.setByPos(index, 9 - index); + } + expect(board.grid[0][0]).toBe(9); + expect(board.grid[0][2]).toBe(7); + expect(board.grid[1][0]).toBe(6); + expect(board.grid[1][1]).toBe(5); + expect(board.grid[2][2]).toBe(1); + }); + + test('prepare', () => { + const board = new Board(); + board.prepare(Difficulty.Easy); + + let emptyCellNb = 0; + for (const cell of board) { + if (cell === undefined) emptyCellNb++; + } + expect(emptyCellNb).toBe(9 - 3); + }); +}); diff --git a/src/lib/core/board.ts b/src/lib/core/board.ts new file mode 100644 index 0000000..0a2261c --- /dev/null +++ b/src/lib/core/board.ts @@ -0,0 +1,130 @@ +export enum Difficulty { + Easy, + Medium, + Hard, + Last, + Done +} + +export type Case = number | undefined; + +function shuffleArray(array: Array): void { + for (let i = array.length - 1; i >= 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} + +function getNumberToKeepFromDifficulty(difficulty: Difficulty) { + const rules = { + // 0: 8, + 0: 3, + 1: 2, + 2: 1, + 3: 8, + 4: 9 + }; + return rules[difficulty]; +} + +export class Board { + grid: Case[][]; + verticalSum: number[]; + horizontalSum: number[]; + lockedCells: boolean[][]; + + constructor() { + const numbers = Array.from({ length: 9 }, (_, i) => i + 1); + shuffleArray(numbers); + + this.grid = []; + this.verticalSum = new Array(3).fill(0); + this.horizontalSum = []; + this.lockedCells = []; + + for (let i = 0; i < 3; i++) { + const row: number[] = []; + for (let j = 0; j < 3; j++) { + row[j] = numbers.pop() || 0; + this.verticalSum[j] += row[j]; + } + this.grid.push(row); + this.horizontalSum[i] = row.reduce((partialSum, a) => partialSum + a, 0); + + this.lockedCells[i] = [false, false, false]; + } + } + + [Symbol.iterator]() { + let index = 0; + return { + next: () => { + if (index < 9) { + const [row, col] = this.positionToRowCol(index++); + return { value: this.grid[row][col], done: false }; + } else { + return { done: true }; + } + } + }; + } + + isComplete(): boolean { + for (const cell of this) { + if (cell === undefined) return false; + } + return true; + } + + isValid(): boolean { + for (let i = 0; i < 3; i++) { + const rowSum = this.grid[i].reduce((partialSum, a) => (partialSum || 0) + (a || 0), 0); + const colSum = (this.grid[0][i] || 0) + (this.grid[1][i] || 0) + (this.grid[2][i] || 0); + if (rowSum !== this.horizontalSum[i] || colSum !== this.verticalSum[i]) return false; + } + return true; + } + + prepare(difficulty: Difficulty) { + const numbers = Array.from({ length: 9 }, (_, i) => i); + shuffleArray(numbers); + const numberToKeep = getNumberToKeepFromDifficulty(difficulty); + const posToHide = numbers.slice(numberToKeep, 9); + + posToHide.forEach((pos) => { + this.setByPos(pos); + // lock visible cells + const [row, col] = this.positionToRowCol(pos); + this.lockedCells[row][col] = true; + }); + } + + reset() { + console.log('ici'); + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + if (this.lockedCells[i][j]) this.grid[i][j] = undefined; + } + } + } + + setByPos(pos: number, value: Case = undefined) { + if (pos < 0 || pos > 8) throw new Error('position must be between 0 and 8!'); + if (value && (value < 1 || value > 9)) throw new Error('value must be between 1 and 9!'); + + const [row, col] = this.positionToRowCol(pos); + this.grid[row][col] = value; + } + + positionToRowCol(position: number) { + return [Math.floor(position / 3), position % 3]; + } + + getPlacedNumbers() { + const res = []; + for (const pos of this) { + if (pos !== undefined) res.push(pos); + } + return res; + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..5bf5167 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,7 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +

Fubuki

+ + diff --git a/svelte.config.js b/svelte.config.js index 64694ad..b8294b4 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -6,9 +6,10 @@ const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors preprocess: vitePreprocess(), - kit: { - adapter: adapter() + adapter: adapter({ + fallback: 'index.html' + }) } };