mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 14:16:10 +00:00
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:
1 parent
3c5fa5a0f2
commit
fb0ef9f783
4 files changed
+100
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user