mirror of
https://github.com/tiennm99/loto.git
synced 2026-09-02 02:19:37 +00:00
feat(game): tân tân lô tô — align master, 5/col, ascending, draw-order
- master board: 11x9 last-digit aligned (col 0 = 1-9, col 8 = 80-90); fixes missing ones-digit-9 row and aligns 1/11/21/.../81 horizontally - master board: each called cell shows 1-based draw order so host can glance across a winning row to verify "Kinh!" - player card: constraint-aware picker guarantees exactly 5 numbers per row AND per column (was loose weighted random allowing 4-6 per col) - player card: numbers within each column now placed ascending top-to-bottom per lô tô hội chợ tân tân convention
This commit is contained in:
+49
-54
@@ -20,41 +20,6 @@ const NUM_ROWS = 9;
|
||||
const NUM_COLS = 9;
|
||||
const NUM_PER_ROW = 5;
|
||||
|
||||
/**
|
||||
* Weighted random selection of a column index.
|
||||
* @param {number[]} weights
|
||||
* @returns {number}
|
||||
*/
|
||||
function randomANumberInRow(weights) {
|
||||
const tempWeight = [...weights];
|
||||
for (let i = 1; i < tempWeight.length; i++) {
|
||||
tempWeight[i] += tempWeight[i - 1];
|
||||
}
|
||||
const rand = Math.floor(Math.random() * tempWeight[tempWeight.length - 1]);
|
||||
for (let i = 0; i < tempWeight.length; i++) {
|
||||
if (rand < tempWeight[i]) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select NUM_PER_ROW columns for a row using weighted random. Mutates baseWeight.
|
||||
* @param {number[]} baseWeight
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function randomARow(baseWeight) {
|
||||
const tempWeight = [...baseWeight];
|
||||
/** @type {number[]} */
|
||||
const selectedCols = [];
|
||||
for (let i = 0; i < NUM_PER_ROW; i++) {
|
||||
const col = randomANumberInRow(tempWeight);
|
||||
selectedCols.push(col);
|
||||
tempWeight[col] = 0;
|
||||
baseWeight[col]--;
|
||||
}
|
||||
return selectedCols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick `num` random numbers from column `col`'s range.
|
||||
* @param {number} num
|
||||
@@ -64,37 +29,67 @@ function randomARow(baseWeight) {
|
||||
function randomNumbersInCol(num, col) {
|
||||
const arr = [...NUM_IN_COL[col]];
|
||||
arr.sort(() => 0.5 - Math.random());
|
||||
return arr.slice(0, num);
|
||||
// Pick `num` at random, then return them ascending so they sit
|
||||
// top-to-bottom in the column (lô tô hội chợ convention).
|
||||
return arr.slice(0, num).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 9x9 lô tô grid. Cell values: 0 = empty, >0 = number.
|
||||
* 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.
|
||||
* @returns {number[][]}
|
||||
*/
|
||||
function pickFilledCols() {
|
||||
const quota = new Array(NUM_COLS).fill(NUM_PER_ROW);
|
||||
/** @type {number[][]} */
|
||||
const result = [];
|
||||
for (let row = 0; row < NUM_ROWS; row++) {
|
||||
const rowsLeft = NUM_ROWS - row;
|
||||
/** @type {number[]} */
|
||||
const forced = [];
|
||||
/** @type {number[]} */
|
||||
const candidates = [];
|
||||
for (let col = 0; col < NUM_COLS; col++) {
|
||||
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 selected = [
|
||||
...forced,
|
||||
...candidates.slice(0, NUM_PER_ROW - forced.length),
|
||||
].sort((a, b) => a - b);
|
||||
for (const col of selected) quota[col]--;
|
||||
result.push(selected);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 9x9 lô tô grid with exactly NUM_PER_ROW filled cells per row
|
||||
* AND per column. Cell values: 0 = empty, >0 = number.
|
||||
* @returns {number[][]}
|
||||
*/
|
||||
export function generateGrid() {
|
||||
const cell = Array.from({ length: NUM_ROWS }, () =>
|
||||
new Array(NUM_COLS).fill(0)
|
||||
);
|
||||
const countNumPerCol = new Array(NUM_COLS).fill(0);
|
||||
const baseWeight = new Array(NUM_COLS).fill(6);
|
||||
|
||||
for (let i = 0; i < NUM_ROWS; i++) {
|
||||
const newRow = randomARow(baseWeight);
|
||||
newRow.forEach((col) => {
|
||||
countNumPerCol[col]++;
|
||||
cell[i][col] = -1;
|
||||
});
|
||||
const colsPerRow = pickFilledCols();
|
||||
for (let row = 0; row < NUM_ROWS; row++) {
|
||||
for (const col of colsPerRow[row]) cell[row][col] = -1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < NUM_COLS; i++) {
|
||||
const selectedNum = randomNumbersInCol(countNumPerCol[i], i);
|
||||
for (let j = 0; j < NUM_ROWS; j++) {
|
||||
if (cell[j][i] === -1) {
|
||||
cell[j][i] = selectedNum.shift() ?? 0;
|
||||
}
|
||||
for (let col = 0; col < NUM_COLS; col++) {
|
||||
const picked = randomNumbersInCol(NUM_PER_ROW, col);
|
||||
for (let row = 0; row < NUM_ROWS; row++) {
|
||||
if (cell[row][col] === -1) cell[row][col] = picked.shift() ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,32 @@
|
||||
const STORAGE_KEY = "loto_master";
|
||||
|
||||
/**
|
||||
* Build the 9x10 board: columns 0-8 map to number ranges 1-9, 10-19, ..., 80-90.
|
||||
* Build the 11x9 board, aligned by last digit so column N row R holds
|
||||
* the number whose tens-digit is N (col 0 = ones, col 8 = eighties/90)
|
||||
* and whose ones-digit is R. Row 0 holds the *0 multiples (10..80),
|
||||
* rows 1-9 hold 1..9, 11..19, ..., 81..89, and row 10 holds 90 alone.
|
||||
* Empty slots are 0.
|
||||
* @returns {number[][]}
|
||||
*/
|
||||
function buildBoard() {
|
||||
/** @type {number[][]} */
|
||||
const board = [];
|
||||
for (let row = 0; row < 10; row++) {
|
||||
for (let row = 0; row < 11; row++) {
|
||||
/** @type {number[]} */
|
||||
const cells = [];
|
||||
for (let col = 0; col < 9; col++) {
|
||||
const num = col === 0 ? row + 1 : col * 10 + row;
|
||||
if (col === 0 && row === 9) cells.push(0);
|
||||
else if (col === 8 && row === 9) cells.push(90);
|
||||
else if (col > 0 && row === 9) cells.push(0);
|
||||
else cells.push(num);
|
||||
let num = 0;
|
||||
if (row === 10) {
|
||||
// Only 90 sits in this trailing row, last column.
|
||||
if (col === 8) num = 90;
|
||||
} else if (row === 0) {
|
||||
// First col has no *0 number in 1-9 range; others get 10, 20, ..., 80.
|
||||
if (col > 0) num = col * 10;
|
||||
} else {
|
||||
// rows 1..9 hold digit `row`: col 0 -> row, col N -> N*10 + row.
|
||||
num = col === 0 ? row : col * 10 + row;
|
||||
}
|
||||
cells.push(num);
|
||||
}
|
||||
board.push(cells);
|
||||
}
|
||||
@@ -77,7 +88,11 @@
|
||||
if (state) saveState(state);
|
||||
});
|
||||
|
||||
const calledSet = $derived(new Set(state?.called ?? []));
|
||||
// Map number -> 1-based draw order; lets the master grid show "this
|
||||
// was the Nth call" for fast Kinh! verification.
|
||||
const callOrder = $derived(
|
||||
new Map((state?.called ?? []).map((n, i) => [n, i + 1])),
|
||||
);
|
||||
|
||||
function handleNewGame() {
|
||||
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
|
||||
@@ -191,7 +206,9 @@
|
||||
<div class="master-grid">
|
||||
{#each BOARD_FLAT as num, idx (idx)}
|
||||
{@const hasNumber = num > 0}
|
||||
{@const isCalled = hasNumber && calledSet.has(num)}
|
||||
{@const order = hasNumber ? callOrder.get(num) : undefined}
|
||||
{@const isCalled = order !== undefined}
|
||||
{@const isLast = isCalled && num === lastCalled}
|
||||
<div
|
||||
class="relative flex items-center justify-center
|
||||
aspect-square text-sm sm:text-base font-bold
|
||||
@@ -199,11 +216,20 @@
|
||||
transition-colors select-none
|
||||
{hasNumber
|
||||
? isCalled
|
||||
? 'bg-orange-500 dark:bg-orange-600 text-white'
|
||||
? isLast
|
||||
? 'bg-red-500 dark:bg-red-600 text-white ring-2 ring-red-300 dark:ring-red-400 ring-inset z-10'
|
||||
: 'bg-orange-500 dark:bg-orange-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200'
|
||||
: 'bg-slate-100 dark:bg-slate-900/60'}"
|
||||
>
|
||||
{hasNumber ? num : ""}
|
||||
{#if isCalled}
|
||||
<span
|
||||
class="absolute top-0.5 right-0.5 text-[9px] sm:text-[10px] font-semibold leading-none text-white/80 tabular-nums"
|
||||
>
|
||||
{order}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user