From cb493be2130e6b2663fd44032261925a66b62ed5 Mon Sep 17 00:00:00 2001 From: Tommy Parnell Date: Tue, 10 Nov 2026 10:41:59 -0500 Subject: [PATCH] shuffle smaller words better --- src/components/Modals.js | 4 + src/components/WordsDrawer.js | 19 ++- src/engine/generator.js | 129 ++++++++++------- src/engine/quartile-tracker.js | 258 +++++++++++++++++++++++++++++++++ src/state/game-state.js | 25 ++++ src/styles/components.css | 21 +++ src/version.json | 2 +- 7 files changed, 405 insertions(+), 53 deletions(-) create mode 100644 src/engine/quartile-tracker.js diff --git a/src/components/Modals.js b/src/components/Modals.js index f9bacf3..dbf670d 100644 --- a/src/components/Modals.js +++ b/src/components/Modals.js @@ -229,6 +229,10 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz Current Rank: ${state.getRank().current.badge} ${state.getRank().current.name} +
+ Quartiles Discovered: + ${state.getUniqueQuartilesSeenCount?.() || 0} / ${state.getTotalTargetWordsCount?.() || 1352} +
`; diff --git a/src/components/WordsDrawer.js b/src/components/WordsDrawer.js index ddde429..387fb07 100644 --- a/src/components/WordsDrawer.js +++ b/src/components/WordsDrawer.js @@ -135,11 +135,20 @@ export function createWordsDrawer({ onRevealAll }) { ` : ''} ` - : displayWords + : ` + ${ + currentTab === 'quartiles' + ? `
+ ⭐ 4-Tile Quartiles: Showing times seen across boards. Boards prioritize least-seen words. +
` + : '' + } + ${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 `
@@ -154,6 +163,11 @@ export function createWordsDrawer({ onRevealAll }) { : 'Found') : 'Revealed' } + ${ + isQuartile + ? `Seen ${seenCount}x` + : '' + }
${(w.tileSegments || []).map(s => `${s.toUpperCase()}`).join('+')} @@ -164,7 +178,8 @@ export function createWordsDrawer({ onRevealAll }) {
`; }) - .join('') + .join('')} + ` } diff --git a/src/engine/generator.js b/src/engine/generator.js index a0cc8f5..078c958 100644 --- a/src/engine/generator.js +++ b/src/engine/generator.js @@ -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 = { diff --git a/src/engine/quartile-tracker.js b/src/engine/quartile-tracker.js new file mode 100644 index 0000000..81a49a2 --- /dev/null +++ b/src/engine/quartile-tracker.js @@ -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); +} + diff --git a/src/state/game-state.js b/src/state/game-state.js index cb4f706..f608ba1 100644 --- a/src/state/game-state.js +++ b/src/state/game-state.js @@ -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(); diff --git a/src/styles/components.css b/src/styles/components.css index 811068f..9149d2c 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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); diff --git a/src/version.json b/src/version.json index e025fe3..bcf6b6e 100644 --- a/src/version.json +++ b/src/version.json @@ -1,3 +1,3 @@ { - "version": "1.0.6" + "version": "1.0.7" }