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:
2026-04-26 23:39:54 +07:00
parent ebae5371b4
commit 189ae3d4e5
2 changed files with 85 additions and 64 deletions
+49 -54
View File
@@ -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;
}
+36 -10
View File
@@ -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>