shuffle smaller words better

This commit is contained in:
2026-11-10 10:41:59 -05:00
parent 46a1772a0b
commit cb493be213
7 changed files with 405 additions and 53 deletions

View File

@@ -229,6 +229,10 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
<span style="color:var(--text-secondary);">Current Rank:</span>
<strong>${state.getRank().current.badge} ${state.getRank().current.name}</strong>
</div>
<div style="display:flex; justify-content:space-between; margin-top:4px; padding-top:4px; border-top:1px dashed var(--border-subtle);">
<span style="color:var(--text-secondary);">Quartiles Discovered:</span>
<strong>${state.getUniqueQuartilesSeenCount?.() || 0} / ${state.getTotalTargetWordsCount?.() || 1352}</strong>
</div>
</div>
</div>
`;

View File

@@ -135,11 +135,20 @@ export function createWordsDrawer({ onRevealAll }) {
</button>
` : ''}
</div>`
: displayWords
: `
${
currentTab === 'quartiles'
? `<div class="quartile-tab-banner">
<span>⭐ <strong>4-Tile Quartiles:</strong> Showing times seen across boards. Boards prioritize least-seen words.</span>
</div>`
: ''
}
${displayWords
.map(w => {
const isFound = state.foundWords.has(w.word.toLowerCase());
const isQuartile = w.tileCount === 4;
const clue = quartileDetailsMap.get(w.word.toLowerCase());
const seenCount = isQuartile ? (state.getQuartileSeenCount?.(w.word) || 0) : 0;
return `
<div class="word-card ${isQuartile ? 'quartile-card' : ''} ${!isFound ? 'revealed-card' : ''}">
@@ -154,6 +163,11 @@ export function createWordsDrawer({ onRevealAll }) {
: '<span class="status-chip found">Found</span>')
: '<span class="status-chip revealed">Revealed</span>'
}
${
isQuartile
? `<span class="status-chip quartile-seen" title="Times this 4-tile Quartile has been seen across boards">Seen ${seenCount}x</span>`
: ''
}
</div>
<div class="word-card-tiles">
${(w.tileSegments || []).map(s => `<span class="tile-chip-mini">${s.toUpperCase()}</span>`).join('+')}
@@ -164,7 +178,8 @@ export function createWordsDrawer({ onRevealAll }) {
</div>
`;
})
.join('')
.join('')}
`
}
</div>

View File

@@ -2,6 +2,11 @@ import { TARGET_WORDS_POOL } from '../data/puzzles.js';
import { solveBoard } from './solver.js';
import { DICTIONARY_SET } from '../data/lexicon.js';
import { createSeededRandom } from './random.js';
import {
getQuartileSeenCount,
recordQuartilesSeen,
selectTargetWordsWithSeenFilter
} from './quartile-tracker.js';
export { createSeededRandom };
@@ -97,19 +102,12 @@ function pickWords(pool, { enforceVariety = true } = {}) {
return null;
}
function selectWords(candidatePool, rng) {
const attempts = [
() => pickWords(weightedShuffle(candidatePool, rng)),
() => pickWords(weightedShuffle(TARGET_WORDS_POOL, rng)),
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
() => pickWords(shuffle(TARGET_WORDS_POOL, rng), { enforceVariety: false })
];
for (const attempt of attempts) {
const result = attempt();
if (result) return result;
}
// Fallback if extreme constraints somehow failed
return shuffle(TARGET_WORDS_POOL, rng).slice(0, 5);
function selectWords(candidatePool, rng, getCountFn = getQuartileSeenCount) {
return selectTargetWordsWithSeenFilter(candidatePool, rng, getCountFn, {
pickWordsFn: pickWords,
weightedShuffleFn: weightedShuffle,
shuffleFn: shuffle
});
}
function buildPuzzle(selectedWords, meta) {
@@ -144,50 +142,28 @@ function themeLabel(selectedWords) {
* - All tiles are at least 2 characters (never 1 letter)
* - Variety: at most one -tion/-sion answer, two 11+ letter answers, and one
* tile that is a short word by itself (e.g. "in", "per")
* - Anti-repetition: tracks recently used words in localStorage across games
* (skipped when a seed is given, so seeded puzzles stay deterministic)
* - Fair distribution: prioritizes words with least views (0 views before 1 view)
* and increments seen counts when new boards are created
*/
export function generateRandomPuzzle(seed) {
const seeded = seed !== undefined && seed !== null;
const rng = seeded ? createSeededRandom(seed) : Math.random;
// Load recently used target words to prevent repetition across games
let recentWords = [];
const selectedWords = selectWords(
TARGET_WORDS_POOL,
rng,
seeded ? () => 0 : getQuartileSeenCount
);
const puzzleId = 'custom-' + Date.now();
// If live game (unseeded), track newly seen quartiles
if (!seeded) {
try {
const rawRecent = localStorage.getItem('quartiles_recent_target_words');
if (rawRecent) {
recentWords = JSON.parse(rawRecent);
if (!Array.isArray(recentWords)) recentWords = [];
}
} catch (e) {
recentWords = [];
}
}
const recentSet = new Set(recentWords.map(w => w.toLowerCase()));
// Filter pool to prioritize words not played recently
const nonRecentPool = TARGET_WORDS_POOL.filter(w => !recentSet.has(w.word.toLowerCase()));
// If non-recent pool is too small, use full pool
const poolCandidate = nonRecentPool.length >= 25 ? nonRecentPool : [...TARGET_WORDS_POOL];
const selectedWords = selectWords(poolCandidate, rng);
// Save selected words into recent words history (keep up to last 50 words)
if (!seeded) {
try {
const updatedRecent = [
...selectedWords.map(w => w.word.toLowerCase()),
...recentWords.filter(w => !selectedWords.some(sw => sw.word.toLowerCase() === w))
].slice(0, 50);
localStorage.setItem('quartiles_recent_target_words', JSON.stringify(updatedRecent));
} catch (e) {
// Non-fatal if localStorage unavailable
}
recordQuartilesSeen(selectedWords.map(w => w.word), puzzleId);
}
return buildPuzzle(selectedWords, {
id: 'custom-' + Date.now(),
id: puzzleId,
date: new Date().toISOString().slice(0, 10),
title: `Unlimited Puzzle #${Math.floor(rng() * 9000 + 1000)}`,
theme: themeLabel(selectedWords),
@@ -195,9 +171,62 @@ export function generateRandomPuzzle(seed) {
});
}
const DAILY_EPOCH = '2026-01-01';
const dailySelectionsCache = new Map();
const dailySequenceSeenCounts = new Map();
let lastComputedDailyDate = null;
function computeDailySelectionsUpTo(targetDateStr) {
if (dailySelectionsCache.has(targetDateStr)) {
return dailySelectionsCache.get(targetDateStr);
}
// Handle dates before epoch
if (targetDateStr < DAILY_EPOCH) {
const rng = createSeededRandom(`quartiles-daily-${targetDateStr}`);
const selected = selectWords(TARGET_WORDS_POOL, rng, () => 0);
dailySelectionsCache.set(targetDateStr, selected);
return selected;
}
let cur;
if (!lastComputedDailyDate) {
cur = new Date(DAILY_EPOCH + 'T00:00:00');
} else {
cur = new Date(lastComputedDailyDate + 'T00:00:00');
cur.setDate(cur.getDate() + 1);
}
const target = new Date(targetDateStr + 'T00:00:00');
while (cur <= target) {
const y = cur.getFullYear();
const m = String(cur.getMonth() + 1).padStart(2, '0');
const d = String(cur.getDate()).padStart(2, '0');
const dStr = `${y}-${m}-${d}`;
if (!dailySelectionsCache.has(dStr)) {
const rng = createSeededRandom(`quartiles-daily-${dStr}`);
const selected = selectWords(
TARGET_WORDS_POOL,
rng,
(w) => dailySequenceSeenCounts.get(w.toLowerCase()) || 0
);
dailySelectionsCache.set(dStr, selected);
for (const item of selected) {
const key = item.word.toLowerCase();
dailySequenceSeenCounts.set(key, (dailySequenceSeenCounts.get(key) || 0) + 1);
}
lastComputedDailyDate = dStr;
}
cur.setDate(cur.getDate() + 1);
}
return dailySelectionsCache.get(targetDateStr);
}
function selectDaily(dateStr) {
const rng = createSeededRandom(`quartiles-daily-${dateStr}`);
const selectedWords = selectWords(TARGET_WORDS_POOL, rng);
const selectedWords = computeDailySelectionsUpTo(dateStr);
const [y, m, d] = dateStr.split('-').map(Number);
const label = new Date(y, m - 1, d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const meta = {

View File

@@ -0,0 +1,258 @@
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
const STORAGE_KEY_SEEN_COUNTS = 'quartiles_quartile_seen_counts';
const STORAGE_KEY_RECORDED_PUZZLES = 'quartiles_recorded_puzzle_ids';
let inMemoryCounts = null;
let inMemoryRecordedPuzzles = null;
function isStorageAvailable() {
try {
return typeof localStorage !== 'undefined' && localStorage !== null;
} catch (e) {
return false;
}
}
function loadCounts() {
if (inMemoryCounts) return inMemoryCounts;
inMemoryCounts = new Map();
if (isStorageAvailable()) {
try {
const raw = localStorage.getItem(STORAGE_KEY_SEEN_COUNTS);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'number' && v > 0) {
inMemoryCounts.set(k.toLowerCase(), v);
}
}
}
}
} catch (e) {
console.warn('Failed to load quartile seen counts:', e);
}
}
return inMemoryCounts;
}
function saveCounts() {
if (!inMemoryCounts || !isStorageAvailable()) return;
try {
const obj = {};
for (const [k, v] of inMemoryCounts.entries()) {
obj[k] = v;
}
localStorage.setItem(STORAGE_KEY_SEEN_COUNTS, JSON.stringify(obj));
} catch (e) {
console.warn('Failed to save quartile seen counts:', e);
}
}
function loadRecordedPuzzles() {
if (inMemoryRecordedPuzzles) return inMemoryRecordedPuzzles;
inMemoryRecordedPuzzles = new Set();
if (isStorageAvailable()) {
try {
const raw = localStorage.getItem(STORAGE_KEY_RECORDED_PUZZLES);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
for (const id of parsed) {
inMemoryRecordedPuzzles.add(String(id));
}
}
}
} catch (e) {
console.warn('Failed to load recorded puzzles:', e);
}
}
return inMemoryRecordedPuzzles;
}
function saveRecordedPuzzles() {
if (!inMemoryRecordedPuzzles || !isStorageAvailable()) return;
try {
const arr = Array.from(inMemoryRecordedPuzzles);
localStorage.setItem(STORAGE_KEY_RECORDED_PUZZLES, JSON.stringify(arr.slice(-500)));
} catch (e) {
console.warn('Failed to save recorded puzzles:', e);
}
}
/**
* Returns how many times a given 4-tile quartile word has been seen
*/
export function getQuartileSeenCount(word) {
if (!word) return 0;
const counts = loadCounts();
return counts.get(word.toLowerCase().trim()) || 0;
}
/**
* Returns all quartile seen counts as a plain object { word: count }
*/
export function getAllQuartileSeenCounts() {
const counts = loadCounts();
const obj = {};
for (const [k, v] of counts.entries()) {
obj[k] = v;
}
return obj;
}
/**
* Returns count of distinct quartile words seen at least once
*/
export function getUniqueQuartilesSeenCount() {
const counts = loadCounts();
return counts.size;
}
/**
* Returns total number of target quartile words in the pool
*/
export function getTotalTargetWordsCount() {
return TARGET_WORDS_POOL.length;
}
/**
* Checks if a puzzle ID has already had its quartiles recorded
*/
export function hasRecordedPuzzle(puzzleId) {
if (!puzzleId) return false;
const set = loadRecordedPuzzles();
return set.has(String(puzzleId));
}
/**
* Increments seen count for the given quartile words.
* If puzzleId is provided, guarantees each puzzle is recorded at most once.
*/
export function recordQuartilesSeen(words, puzzleId = null) {
if (!Array.isArray(words) || words.length === 0) return false;
if (puzzleId) {
const recorded = loadRecordedPuzzles();
if (recorded.has(String(puzzleId))) {
return false; // Already recorded for this puzzle instance
}
recorded.add(String(puzzleId));
saveRecordedPuzzles();
}
const counts = loadCounts();
for (const w of words) {
if (!w) continue;
const key = (typeof w === 'string' ? w : w.word || '').toLowerCase().trim();
if (!key) continue;
counts.set(key, (counts.get(key) || 0) + 1);
}
saveCounts();
return true;
}
/**
* Reset all tracked quartile view counts and recorded puzzles
*/
export function resetQuartileSeenCounts() {
inMemoryCounts = new Map();
inMemoryRecordedPuzzles = new Set();
if (isStorageAvailable()) {
try {
localStorage.removeItem(STORAGE_KEY_SEEN_COUNTS);
localStorage.removeItem(STORAGE_KEY_RECORDED_PUZZLES);
} catch (e) {}
}
}
/**
* Core selection algorithm:
* "Don't include words that have been seen more than the rest of the words.
* So if there's still words with 0 views show those before any that have seen 1 time.
* If there's not enough words left not seen more than the rest and you are making a new board
* then use the ones seen and increment those being seen.
* Basically, seen less than the other words == include in board, seen more == exclude from boards"
*/
export function selectTargetWordsWithSeenFilter(
pool,
rng,
getCountFn,
{ pickWordsFn, weightedShuffleFn, shuffleFn } = {}
) {
const getCount = getCountFn || getQuartileSeenCount;
if (!pool || pool.length === 0) return [];
if (pool.length <= 5) return pool;
// 1. Find minimum seen count in the pool
let minCount = Infinity;
for (const item of pool) {
const c = getCount(item.word);
if (c < minCount) minCount = c;
}
// 2. Partition candidate pool into tiers by seen count
const tierMin = [];
const tierNext = [];
const tierRest = [];
for (const item of pool) {
const c = getCount(item.word);
if (c === minCount) {
tierMin.push(item);
} else if (c === minCount + 1) {
tierNext.push(item);
} else {
tierRest.push(item);
}
}
const shuffle =
shuffleFn ||
(arr => {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
});
const weightShuffle = weightedShuffleFn || shuffle;
const pick = pickWordsFn;
// Attempt 1: Strict minSeen only (0 views before 1 view)
let result = pick(weightShuffle(tierMin, rng));
if (result) return result;
// Attempt 2: If minSeen alone cannot form 5 non-clashing words,
// greedily pick from tierMin first, filling only remaining slots from tierNext
if (tierNext.length > 0) {
result = pick([...weightShuffle(tierMin, rng), ...weightShuffle(tierNext, rng)]);
if (result) return result;
result = pick([...shuffle(tierMin, rng), ...shuffle(tierNext, rng)]);
if (result) return result;
result = pick([...shuffle(tierMin, rng), ...shuffle(tierNext, rng)], { enforceVariety: false });
if (result) return result;
}
// Attempt 3: Relax variety limits on tierMin
result = pick(shuffle(tierMin, rng), { enforceVariety: false });
if (result) return result;
// Attempt 4: Expand to tierRest if strictly needed
if (tierRest.length > 0) {
result = pick(
[...shuffle(tierMin, rng), ...shuffle(tierNext, rng), ...shuffle(tierRest, rng)],
{ enforceVariety: false }
);
if (result) return result;
}
// Fallback fallback
return shuffle(pool, rng).slice(0, 5);
}

View File

@@ -2,6 +2,12 @@ import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '..
import { isAcceptedWord } from '../data/lexicon.js';
import { resolveBuiltInPuzzle, isBuiltInPuzzleId, getDailyPuzzle, toDateString } from '../engine/daily.js';
import { trackGameStart, trackGameFinish } from '../engine/analytics.js';
import {
recordQuartilesSeen,
getQuartileSeenCount,
getUniqueQuartilesSeenCount,
getTotalTargetWordsCount
} from '../engine/quartile-tracker.js';
// Identifies the exact board, so saves from an older version of a puzzle id are ignored
function boardSignature(puzzle) {
@@ -81,6 +87,12 @@ class GameStateManager {
// Organize board tiles according to lock state
this.reorganizeBoard();
// Track quartiles seen for this puzzle (at most once per puzzle ID)
const quartilesToRecord = puzzle.targetQuartiles || puzzle.quartileDetails?.map(q => q.word) || [];
if (quartilesToRecord.length > 0) {
recordQuartilesSeen(quartilesToRecord, puzzle.id);
}
// Track game start in analytics
const rank = this.getRank();
const isCompleted = rank.isPerfect || this.isRevealed;
@@ -665,6 +677,19 @@ class GameStateManager {
return { totalWordsFound: 0, expertCount: 0, perfectCount: 0 };
}
}
// Quartile view tracking helpers
getQuartileSeenCount(word) {
return getQuartileSeenCount(word);
}
getUniqueQuartilesSeenCount() {
return getUniqueQuartilesSeenCount();
}
getTotalTargetWordsCount() {
return getTotalTargetWordsCount();
}
}
export const gameState = new GameStateManager();

View File

@@ -596,6 +596,27 @@
color: var(--accent-quartile);
}
.status-chip.quartile-seen {
background: rgba(59, 130, 246, 0.12);
color: var(--accent-blue);
border: 1px solid rgba(59, 130, 246, 0.28);
}
.quartile-tab-banner {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(59, 130, 246, 0.06));
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: var(--radius-md);
padding: 8px 12px;
margin-bottom: 12px;
font-size: 0.78rem;
color: var(--text-secondary);
line-height: 1.4;
}
.quartile-tab-banner strong {
color: var(--text-primary);
}
.quartile-clue {
font-size: 0.75rem;
color: var(--text-secondary);

View File

@@ -1,3 +1,3 @@
{
"version": "1.0.6"
"version": "1.0.7"
}