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
This commit is contained in:
tiennm99 committed 2026-04-27 07:57:10 +07:00
1 parent 3c5fa5a0f2
commit fb0ef9f783
4 files changed
+100 -15

No files matched your search

+1 -1
View File
@@ -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 `<html class="dark">` on `:root`. Per-key validators preserve old data. |
### Styling
+5
View File
@@ -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).
+80 -14
View File
@@ -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.
+14
View File
@@ -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);