From fb0ef9f7838caddbd905fd7bd31d9d7fd774c5a7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 27 Apr 2026 07:57:10 +0700 Subject: [PATCH] feat(card): avoid 3 consecutive filled columns in any row Soft visual constraint: no row has cols n, n+1, n+2 all filled. Implementation = constraint-aware per-row picker (uniformly samples triple-free completions of the forced+candidate set) + whole-grid rejection sampling (up to 200 attempts). Hard invariants (5 per row, 5 per col, ascending column values) are never sacrificed; if the soft constraint can't be met, the generator returns the best attempt. - src/lib/game-logic.js: hasThreeInARow, combinations, pickFilledColsOnce, pickFilledCols rejection wrapper - src/lib/game-logic.test.js: 300-trial strict assertion - docs/codebase-summary.md, project-overview-pdr.md: note the rule --- docs/codebase-summary.md | 2 +- docs/project-overview-pdr.md | 5 ++ src/lib/game-logic.js | 94 ++++++++++++++++++++++++++++++------ src/lib/game-logic.test.js | 14 ++++++ 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 6543a9a..953e3ea 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -19,7 +19,7 @@ ### Game Logic | File | Purpose | |------|---------| -| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. | +| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. | | `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. Stores 5 keys: `theme` (enum: "auto" / "light" / "dark"), `masterMode` (bool), `autoCallEnabled` (bool), `autoCallSpeed` (1–10), `emptyCellColor` (hex). Persisted to localStorage `loto_settings`. Pushes values to CSS vars and `` on `:root`. Per-key validators preserve old data. | ### Styling diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 7046ee4..7e950e7 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -23,6 +23,10 @@ That format is intentionally out of scope. - **Column ranges**: col 0 = 1–9, col 1 = 10–19, …, col 7 = 70–79, col 8 = 80–90. - **Within a column**: numbers placed top-to-bottom in **ascending** order. +- **Visual rhythm (soft)**: the generator avoids rows with 3 consecutive + filled columns whenever possible (rejection-sampled per card). Hard + invariants (5 per row + 5 per col) always win if the constraint can't + be satisfied. - **Number pool**: 1–90. - **Win condition**: 1 row complete = **"Kinh!"**. After winning, the player may keep playing further rows (game does not end). @@ -89,6 +93,7 @@ State is entirely client-side. Each card / panel instance uses a unique localSto - [x] Numbers within each column are **ascending top-to-bottom**. - [x] Player can click cells to toggle crossed state. - [x] Player can clear all marks on the current card without regenerating it (confirm prompt when marks exist). +- [x] No row has 3 consecutive filled columns (soft constraint, rejection-sampled). - [x] Bingo popup triggers when row is complete, shows row number and "Kinh!" message. - [x] Player may keep marking after a Kinh — no game-end lock. - [x] Toast notifications show "Chờ X" before bingo (one number remaining). diff --git a/src/lib/game-logic.js b/src/lib/game-logic.js index a6891f1..ad4ba6f 100644 --- a/src/lib/game-logic.js +++ b/src/lib/game-logic.js @@ -35,15 +35,45 @@ function randomNumbersInCol(num, col) { } /** - * Choose which columns are filled in each row so that every row has exactly - * NUM_PER_ROW filled cells AND every column ends up with exactly NUM_PER_ROW - * filled cells. Forces any column whose remaining quota equals the number of - * rows left — otherwise that column could not reach its target — then picks - * the rest at random from columns with quota > 0. The forced set never - * exceeds NUM_PER_ROW because total remaining quota = NUM_PER_ROW * rowsLeft. + * Sorted strictly-ascending column indices contain 3 consecutive integers? + * Soft "no triple" constraint enforcer for a single row. + * @param {number[]} cols + */ +function hasThreeInARow(cols) { + for (let i = 0; i + 2 < cols.length; i++) { + if (cols[i + 1] === cols[i] + 1 && cols[i + 2] === cols[i] + 2) return true; + } + return false; +} + +/** + * Enumerate every k-sized combination of `arr` (preserves input order). + * @param {number[]} arr + * @param {number} k * @returns {number[][]} */ -function pickFilledCols() { +function combinations(arr, k) { + if (k === 0) return [[]]; + if (arr.length < k) return []; + /** @type {number[][]} */ + const out = []; + for (let i = 0; i <= arr.length - k; i++) { + const head = arr[i]; + for (const tail of combinations(arr.slice(i + 1), k - 1)) { + out.push([head, ...tail]); + } + } + return out; +} + +/** + * One attempt at picking the row-by-row column selection. Per-row picker + * prefers triple-free completions; if any row's forced set is already a + * triple (or no completion is triple-free), that row falls back to an + * unconstrained pick so the hard column-quota invariant never breaks. + * @returns {number[][]} + */ +function pickFilledColsOnce() { const quota = new Array(NUM_COLS).fill(NUM_PER_ROW); /** @type {number[][]} */ const result = []; @@ -57,20 +87,56 @@ function pickFilledCols() { if (quota[col] === rowsLeft) forced.push(col); else if (quota[col] > 0) candidates.push(col); } - for (let i = candidates.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; + const need = NUM_PER_ROW - forced.length; + + /** @type {number[][]} */ + const validCompletions = []; + if (!hasThreeInARow(forced)) { + for (const combo of combinations(candidates, need)) { + const merged = [...forced, ...combo].sort((a, b) => a - b); + if (!hasThreeInARow(merged)) validCompletions.push(merged); + } } - const selected = [ - ...forced, - ...candidates.slice(0, NUM_PER_ROW - forced.length), - ].sort((a, b) => a - b); + + let selected; + if (validCompletions.length > 0) { + selected = + validCompletions[Math.floor(Math.random() * validCompletions.length)]; + } else { + for (let i = candidates.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; + } + selected = [...forced, ...candidates.slice(0, need)].sort((a, b) => a - b); + } + for (const col of selected) quota[col]--; result.push(selected); } return result; } +/** + * Choose which columns are filled in each row so that every row has exactly + * NUM_PER_ROW filled cells AND every column ends up with exactly NUM_PER_ROW + * filled cells. Soft constraint: no row has 3 consecutive filled columns. + * + * Strategy: per-row picker greedily prefers triple-free completions. Because + * early-row choices can still corner late rows into a forced triple, we wrap + * the whole pass in rejection sampling. If every attempt fails (extremely + * rare), the last attempt is returned — column quotas hold either way. + * @returns {number[][]} + */ +function pickFilledCols() { + const MAX_ATTEMPTS = 200; + let last = pickFilledColsOnce(); + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + if (last.every((row) => !hasThreeInARow(row))) return last; + last = pickFilledColsOnce(); + } + return last; +} + /** * Generate a 9x9 lô tô grid with exactly NUM_PER_ROW filled cells per row * AND per column. Cell values: 0 = empty, >0 = number. diff --git a/src/lib/game-logic.test.js b/src/lib/game-logic.test.js index 1da2263..997844f 100644 --- a/src/lib/game-logic.test.js +++ b/src/lib/game-logic.test.js @@ -94,6 +94,20 @@ describe("generateGrid — column number ranges (lô tô hội chợ Tân Tân)" } }); + it("no row has 3 consecutive filled columns (rejection-sampled soft constraint)", () => { + for (let trial = 0; trial < 300; trial++) { + const g = generateGrid(); + for (let r = 0; r < NUM_ROWS; r++) { + for (let c = 0; c + 2 < NUM_COLS; c++) { + expect( + !(g[r][c] > 0 && g[r][c + 1] > 0 && g[r][c + 2] > 0), + `trial=${trial} row=${r} cols ${c},${c + 1},${c + 2}`, + ).toBe(true); + } + } + } + }); + it("col 0 only holds numbers from 1-9 (5 per card)", () => { const g = generateGrid(); const col0 = g.map((r) => r[0]).filter((n) => n > 0);