many many fixes
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Git hooks run under sh and must keep LF line endings (even on Windows checkouts)
|
||||
.githooks/* text eol=lf
|
||||
28
.githooks/pre-commit
Normal file
28
.githooks/pre-commit
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# 1. Regenerates game data (npm run data) and stages the generated files.
|
||||
# 2. Bumps the app version (src/version.json) and stages it.
|
||||
# Installed via `npm install` (scripts/install-hooks.mjs sets core.hooksPath).
|
||||
# Skip with: git commit --no-verify
|
||||
# or skip one step: SKIP_DATA_BUILD=1 / SKIP_VERSION_BUMP=1 git commit ...
|
||||
|
||||
# Nothing staged: let git report "nothing to commit" without doing any work
|
||||
if git diff --cached --quiet; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$SKIP_DATA_BUILD" ]; then
|
||||
echo "pre-commit: regenerating game data (npm run data)..."
|
||||
LOG_FILE="$(git rev-parse --git-dir)/data-build.log"
|
||||
if ! npm run data > "$LOG_FILE" 2>&1; then
|
||||
echo "pre-commit: npm run data failed, commit aborted. Last lines of $LOG_FILE:"
|
||||
tail -n 20 "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# Stage generated outputs (no-op for files that didn't change)
|
||||
git add src/data/lexicon.js src/data/puzzles.js scripts/combo-words.txt || exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$SKIP_VERSION_BUMP" ]; then
|
||||
node scripts/bump-version.mjs || exit 1
|
||||
git add src/version.json || exit 1
|
||||
fi
|
||||
18
README.md
18
README.md
@@ -46,7 +46,7 @@ Every puzzle contains **5 target 4-tile Quartiles**. Finding all 5 target Quarti
|
||||
- **Shuffle & Sort A-Z**: Reorder unlocked tiles to spot new combinations or sort them alphabetically.
|
||||
- **Interactive Words Found Drawer**: Filter solved words by tile count (*All, Quartiles, 3-Tiles, 2-Tiles, 1-Tile*), view point values, and tile fragment breakdowns.
|
||||
- **Progressive Hint System**: Contextual hints guiding you to unfound Quartiles and words without spoiling the puzzle.
|
||||
- **Daily Calendar & Archives**: Over 30 curated daily and themed challenge puzzles (*Cosmos, Urban Pulse, Wild Kingdom, Mind & Memory, etc.*).
|
||||
- **Daily Puzzles & Archive**: A new generated puzzle every day (seeded by date, so everyone gets the same board), with the last three weeks available in the calendar.
|
||||
- **Unlimited Random Mode**: Instant on-the-fly puzzle generator yielding infinite unique playable boards.
|
||||
- **Custom Puzzle Creator & Sharing**: Create your own puzzle using 5 custom four-tile words and share with friends via URL hash.
|
||||
- **Tactile Web Audio Feedback**: Realistic acoustic clicks, pops, harmonious chords, and celebratory fanfare built using the native Web Audio API (0 external sound files needed).
|
||||
@@ -105,17 +105,21 @@ quartiles/
|
||||
│ │ ├── ActionBar.js # Shuffle, Sort A-Z, Lock Quartiles, Hint
|
||||
│ │ ├── WordsDrawer.js # Categorized found words bottom sheet
|
||||
│ │ ├── Modals.js # How to Play, Calendar, Builder, Stats, Settings
|
||||
│ │ └── Toast.js # Spring floating notifications
|
||||
│ │ ├── Toast.js # Spring floating notifications
|
||||
│ │ └── UpdateBanner.js # "New version available" reload prompt
|
||||
│ ├── data/
|
||||
│ │ ├── puzzles.js # Pre-solved daily and themed puzzles database
|
||||
│ │ ├── puzzles.js # Target word pool (all puzzles are generated from it)
|
||||
│ │ └── lexicon.js # SCOWL recognized English vocabulary set
|
||||
│ ├── version.json # App version (bumped by the pre-commit hook)
|
||||
│ └── engine/
|
||||
│ ├── solver.js # Tree-pruning word solver and rank calculator
|
||||
│ ├── generator.js # Unlimited and custom puzzle generator & URL encoder
|
||||
│ ├── generator.js # Daily (seeded), unlimited and custom puzzle generator
|
||||
│ ├── daily.js # Daily puzzle lookup and calendar list
|
||||
│ ├── version.js # Polls /version.json for new deploys
|
||||
│ ├── audio.js # Web Audio API acoustic synthesizer
|
||||
│ └── confetti.js # Canvas particle confetti engine
|
||||
└── scripts/
|
||||
├── solver.mjs # Node solver script
|
||||
├── build-all-data.mjs # Curated puzzle builder
|
||||
└── build-client-lexicon.mjs
|
||||
├── build-all-data.mjs # Builds the target word pool (src/data/puzzles.js)
|
||||
├── build-client-lexicon.mjs
|
||||
└── bump-version.mjs # Version bump (run by .githooks/pre-commit)
|
||||
```
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "sirv dist --single --host 0.0.0.0 --port ${PORT:-3000}",
|
||||
"data": "node scripts/build-client-lexicon.mjs && node scripts/build-all-data.mjs && node scripts/find-missing-combo-words.mjs --write && node scripts/build-client-lexicon.mjs"
|
||||
"data": "node scripts/build-client-lexicon.mjs && node scripts/build-all-data.mjs && node scripts/find-missing-combo-words.mjs --write && node scripts/build-client-lexicon.mjs",
|
||||
"version:bump": "node scripts/bump-version.mjs",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -39,6 +39,9 @@ self.addEventListener('fetch', event => {
|
||||
// Only handle GET requests
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
// Version checks must always hit the network (never cached)
|
||||
if (url.origin === location.origin && url.pathname === '/version.json') return;
|
||||
|
||||
// Handle HTML navigation requests (Offline support)
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { solveBoard } from './solver.mjs';
|
||||
import { extraWords } from './extra-words.mjs';
|
||||
import { DICTIONARY_SET } from '../src/data/lexicon.js';
|
||||
|
||||
@@ -228,210 +227,9 @@ for (const item of targetWordsPool) {
|
||||
|
||||
console.log('Target words pool verified! Total words:', targetWordsPool.length);
|
||||
|
||||
const dailyPuzzlesDefs = [
|
||||
{
|
||||
id: '2026-10-23',
|
||||
date: '2026-10-23',
|
||||
title: 'Daily Puzzle — Oct 23, 2026',
|
||||
theme: 'Discovery & Wonder',
|
||||
difficulty: 'Normal',
|
||||
words: ['remarkable', 'unforgettable', 'photography', 'adventurous', 'inspiration']
|
||||
},
|
||||
{
|
||||
id: '2026-10-24',
|
||||
date: '2026-10-24',
|
||||
title: 'Daily Puzzle — Oct 24, 2026',
|
||||
theme: 'Cosmic Horizons',
|
||||
difficulty: 'Challenging',
|
||||
words: ['astronomy', 'gravitation', 'atmosphere', 'interstellar', 'telescopic']
|
||||
},
|
||||
{
|
||||
id: '2026-10-25',
|
||||
date: '2026-10-25',
|
||||
title: 'Daily Puzzle — Oct 25, 2026',
|
||||
theme: 'Mind & Memory',
|
||||
difficulty: 'Normal',
|
||||
words: ['imagination', 'understanding', 'psychology', 'intelligence', 'consciousness']
|
||||
},
|
||||
{
|
||||
id: '2026-10-26',
|
||||
date: '2026-10-26',
|
||||
title: 'Daily Puzzle — Oct 26, 2026',
|
||||
theme: 'Urban Pulse',
|
||||
difficulty: 'Challenging',
|
||||
words: ['architecture', 'metropolitan', 'construction', 'neighborhood', 'skyscraper']
|
||||
},
|
||||
{
|
||||
id: '2026-10-27',
|
||||
date: '2026-10-27',
|
||||
title: 'Daily Puzzle — Oct 27, 2026',
|
||||
theme: 'Harmony & Arts',
|
||||
difficulty: 'Normal',
|
||||
words: ['saxophone', 'masterpiece', 'calligraphy', 'exhibition', 'creativity']
|
||||
},
|
||||
{
|
||||
id: '2026-10-28',
|
||||
date: '2026-10-28',
|
||||
title: 'Daily Puzzle — Oct 28, 2026',
|
||||
theme: 'Digital Future',
|
||||
difficulty: 'Normal',
|
||||
words: ['technology', 'electronic', 'automation', 'computer', 'cybersecurity']
|
||||
},
|
||||
{
|
||||
id: '2026-10-29',
|
||||
date: '2026-10-29',
|
||||
title: 'Daily Puzzle — Oct 29, 2026',
|
||||
theme: 'Celebrations',
|
||||
difficulty: 'Easy',
|
||||
words: ['celebration', 'fellowship', 'generosity', 'opportunity', 'marshmallow']
|
||||
},
|
||||
{
|
||||
id: '2026-10-30',
|
||||
date: '2026-10-30',
|
||||
title: 'Daily Puzzle — Oct 30, 2026',
|
||||
theme: 'Wild Kingdom',
|
||||
difficulty: 'Normal',
|
||||
words: ['biodiversity', 'environment', 'caterpillar', 'wilderness', 'chameleon']
|
||||
},
|
||||
{
|
||||
id: '2026-10-31',
|
||||
date: '2026-10-31',
|
||||
title: 'Daily Puzzle — Oct 31, 2026',
|
||||
theme: 'Mysteries & Legends',
|
||||
difficulty: 'Challenging',
|
||||
words: ['mysterious', 'supernatural', 'legendary', 'enchanted', 'miraculous']
|
||||
},
|
||||
{
|
||||
id: '2026-10-22',
|
||||
date: '2026-10-22',
|
||||
title: 'Daily Puzzle — Oct 22, 2026',
|
||||
theme: 'Scientific Insight',
|
||||
difficulty: 'Normal',
|
||||
words: ['equivalent', 'hypothesis', 'laboratory', 'microscope', 'phenomenon']
|
||||
},
|
||||
{
|
||||
id: '2026-10-21',
|
||||
date: '2026-10-21',
|
||||
title: 'Daily Puzzle — Oct 21, 2026',
|
||||
theme: 'Wordsmith Almanac',
|
||||
difficulty: 'Normal',
|
||||
words: ['dictionary', 'manuscript', 'encyclopedia', 'literature', 'vocabulary']
|
||||
},
|
||||
{
|
||||
id: '2026-10-20',
|
||||
date: '2026-10-20',
|
||||
title: 'Daily Puzzle — Oct 20, 2026',
|
||||
theme: 'Globe Trekker',
|
||||
difficulty: 'Normal',
|
||||
words: ['backpacker', 'locomotive', 'continental', 'navigation', 'voyagers']
|
||||
},
|
||||
{
|
||||
id: '2026-10-19',
|
||||
date: '2026-10-19',
|
||||
title: 'Daily Puzzle — Oct 19, 2026',
|
||||
theme: 'Atmospheric Skies',
|
||||
difficulty: 'Challenging',
|
||||
words: ['meteorology', 'temperatures', 'precipitation', 'stratosphere', 'cloudburst']
|
||||
},
|
||||
{
|
||||
id: '2026-10-18',
|
||||
date: '2026-10-18',
|
||||
title: 'Daily Puzzle — Oct 18, 2026',
|
||||
theme: 'Green Oasis',
|
||||
difficulty: 'Normal',
|
||||
words: ['photosynthesis', 'marigold', 'evergreen', 'germination', 'sunflowers']
|
||||
},
|
||||
{
|
||||
id: '2026-10-17',
|
||||
date: '2026-10-17',
|
||||
title: 'Daily Puzzle — Oct 17, 2026',
|
||||
theme: 'Grit & Glory',
|
||||
difficulty: 'Normal',
|
||||
words: ['determination', 'courageous', 'championship', 'achievement', 'perseverance']
|
||||
},
|
||||
{
|
||||
id: '2026-10-16',
|
||||
date: '2026-10-16',
|
||||
title: 'Daily Puzzle — Oct 16, 2026',
|
||||
theme: 'Oceanic Depths',
|
||||
difficulty: 'Challenging',
|
||||
words: ['oceanography', 'submarine', 'underwater', 'jellyfish', 'exploration']
|
||||
},
|
||||
{
|
||||
id: '2026-10-15',
|
||||
date: '2026-10-15',
|
||||
title: 'Daily Puzzle — Oct 15, 2026',
|
||||
theme: 'Energy & Waves',
|
||||
difficulty: 'Expert',
|
||||
words: ['electricity', 'spectrum', 'frequency', 'radioactive', 'magnetism']
|
||||
}
|
||||
];
|
||||
|
||||
// Map target words for quick lookup
|
||||
const wordMap = new Map();
|
||||
for (const item of targetWordsPool) {
|
||||
wordMap.set(item.word, item);
|
||||
}
|
||||
|
||||
const solvedPuzzles = [];
|
||||
|
||||
for (const def of dailyPuzzlesDefs) {
|
||||
const quartets = def.words.map(w => {
|
||||
const found = wordMap.get(w);
|
||||
if (!found) throw new Error('Missing word in pool: ' + w);
|
||||
return found.tiles;
|
||||
});
|
||||
|
||||
const allTiles = quartets.flat();
|
||||
// STRICT ASSERTIONS
|
||||
if (new Set(allTiles).size !== 20) {
|
||||
const counts = {};
|
||||
allTiles.forEach(t => counts[t] = (counts[t] || 0) + 1);
|
||||
const dupes = Object.entries(counts).filter(([_, c]) => c > 1);
|
||||
throw new Error(`Puzzle ${def.id} has duplicate tiles: ${JSON.stringify(dupes)}`);
|
||||
}
|
||||
if (allTiles.some(t => t.length < 2)) {
|
||||
throw new Error(`Puzzle ${def.id} has a tile < 2 chars: ${JSON.stringify(allTiles)}`);
|
||||
}
|
||||
|
||||
const solved = solveBoard(quartets, 30);
|
||||
|
||||
if (solved.wordCount > 30) {
|
||||
throw new Error(`Puzzle ${def.id} exceeds 30 words: ${solved.wordCount}`);
|
||||
}
|
||||
|
||||
// Add clues to target quartiles
|
||||
const quartileDetails = def.words.map(w => {
|
||||
const item = wordMap.get(w);
|
||||
return {
|
||||
word: item.word,
|
||||
tiles: item.tiles,
|
||||
clue: item.clue
|
||||
};
|
||||
});
|
||||
|
||||
solvedPuzzles.push({
|
||||
id: def.id,
|
||||
date: def.date,
|
||||
title: def.title,
|
||||
theme: def.theme,
|
||||
difficulty: def.difficulty,
|
||||
quartets,
|
||||
tiles: solved.tiles,
|
||||
targetQuartiles: solved.targetQuartiles,
|
||||
quartileDetails,
|
||||
words: solved.words,
|
||||
wordCount: solved.wordCount,
|
||||
maxPossibleScore: solved.maxPossibleScore
|
||||
});
|
||||
|
||||
console.log(`Generated: ${def.id} [${def.theme}] -> ${solved.wordCount} words, max score: ${solved.maxPossibleScore}`);
|
||||
}
|
||||
|
||||
// Write solved puzzles to src/data/puzzles.js
|
||||
const puzzlesFileContent = `// Pre-computed solved daily and themed puzzles for Quartiles
|
||||
export const CURATED_PUZZLES = ${JSON.stringify(solvedPuzzles, null, 2)};
|
||||
|
||||
// Puzzles are generated from this pool at runtime (src/engine/generator.js):
|
||||
// daily puzzles are seeded by date, unlimited puzzles are random.
|
||||
const puzzlesFileContent = `// Target word pool for generated Quartiles puzzles (built by scripts/build-all-data.mjs)
|
||||
export const TARGET_WORDS_POOL = ${JSON.stringify(targetWordsPool, null, 2)};
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { buildWordSet, loadComboWords } from './lexicon-sources.mjs';
|
||||
import { buildWordSet, loadComboWords, getCommonnessScore } from './lexicon-sources.mjs';
|
||||
|
||||
// Build a clean, high-frequency recognized vocabulary for client-side unlimited/custom puzzles
|
||||
// (includes regional spellings and the proper-word allowlist)
|
||||
@@ -13,12 +13,26 @@ console.log('Accept-only combo words:', comboWords.length);
|
||||
|
||||
// Pack into space-separated string or array
|
||||
const sortedWords = [...vocabulary].sort();
|
||||
// One digit (0-4) per word, parallel to RAW_LEXICON. Compresses to almost nothing.
|
||||
const commonnessDigits = sortedWords.map(w => getCommonnessScore(w)).join('');
|
||||
const lexiconContent = `// Client-side recognized English words for solver & custom puzzles
|
||||
// Loaded as a Set for O(1) instantaneous lookup
|
||||
const RAW_LEXICON = ${JSON.stringify(sortedWords)};
|
||||
|
||||
export const DICTIONARY_SET = new Set(RAW_LEXICON);
|
||||
|
||||
// Commonness tier per word (4 = most common, 0 = rare), parallel to RAW_LEXICON
|
||||
const RAW_COMMONNESS = ${JSON.stringify(commonnessDigits)};
|
||||
const COMMONNESS_MAP = new Map();
|
||||
for (let i = 0; i < RAW_LEXICON.length; i++) {
|
||||
const c = RAW_COMMONNESS.charCodeAt(i) - 48;
|
||||
if (c > 0) COMMONNESS_MAP.set(RAW_LEXICON[i], c);
|
||||
}
|
||||
|
||||
export function getCommonnessScore(word) {
|
||||
return COMMONNESS_MAP.get(word) || 0;
|
||||
}
|
||||
|
||||
// Accept-only words buildable from pool tiles (generated by
|
||||
// scripts/find-missing-combo-words.mjs). Space-packed to keep the bundle small.
|
||||
const RAW_COMBO_WORDS = ${JSON.stringify(comboWords.join(' '))};
|
||||
|
||||
33
scripts/bump-version.mjs
Normal file
33
scripts/bump-version.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// Bumps the app version in src/version.json.
|
||||
//
|
||||
// node scripts/bump-version.mjs -> patch (1.0.0 -> 1.0.1)
|
||||
// node scripts/bump-version.mjs minor -> 1.0.1 -> 1.1.0
|
||||
// node scripts/bump-version.mjs major -> 1.1.0 -> 2.0.0
|
||||
//
|
||||
// Run automatically by the pre-commit hook (.githooks/pre-commit).
|
||||
import fs from 'node:fs';
|
||||
|
||||
const VERSION_FILE = new URL('../src/version.json', import.meta.url);
|
||||
const level = (process.argv[2] || 'patch').toLowerCase();
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(VERSION_FILE, 'utf8'));
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(data.version || '');
|
||||
if (!match) {
|
||||
console.error(`bump-version: invalid version "${data.version}" in src/version.json (expected X.Y.Z)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [major, minor, patch] = match.slice(1).map(Number);
|
||||
if (level === 'major') { major++; minor = 0; patch = 0; }
|
||||
else if (level === 'minor') { minor++; patch = 0; }
|
||||
else if (level === 'patch') { patch++; }
|
||||
else {
|
||||
console.error(`bump-version: unknown level "${level}" (use patch, minor or major)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const previous = data.version;
|
||||
data.version = `${major}.${minor}.${patch}`;
|
||||
fs.writeFileSync(VERSION_FILE, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
console.log(`bump-version: ${previous} -> ${data.version}`);
|
||||
@@ -8,16 +8,15 @@
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { buildWordSet } from './lexicon-sources.mjs';
|
||||
import { TARGET_WORDS_POOL, CURATED_PUZZLES } from '../src/data/puzzles.js';
|
||||
import { TARGET_WORDS_POOL } from '../src/data/puzzles.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const REFERENCE = require('an-array-of-english-words');
|
||||
const OUT_FILE = 'scripts/combo-words.txt';
|
||||
|
||||
// Every tile that can appear on a board (random + daily puzzles)
|
||||
// Every tile that can appear on a board (all puzzles are generated from the pool)
|
||||
const chunks = new Set();
|
||||
TARGET_WORDS_POOL.forEach(t => t.tiles.forEach(c => chunks.add(c.toLowerCase())));
|
||||
CURATED_PUZZLES.forEach(p => p.quartets.flat().forEach(c => chunks.add(c.toLowerCase())));
|
||||
const maxChunkLen = Math.max(...[...chunks].map(c => c.length));
|
||||
|
||||
// Base dictionary WITHOUT the previously generated combo list, so re-runs are stable
|
||||
|
||||
15
scripts/install-hooks.mjs
Normal file
15
scripts/install-hooks.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
// Points git at the repo's .githooks directory. Runs on `npm install`
|
||||
// (the "prepare" script). Never fails, so installs work without git
|
||||
// (e.g. CI / Nixpacks builds).
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
try {
|
||||
if (!fs.existsSync('.git')) process.exit(0);
|
||||
execSync('git config core.hooksPath .githooks', { stdio: 'ignore' });
|
||||
// Make sure the hook is executable on macOS/Linux
|
||||
try { fs.chmodSync('.githooks/pre-commit', 0o755); } catch {}
|
||||
console.log('Git hooks installed (.githooks)');
|
||||
} catch {
|
||||
// git not available: skip silently
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { buildWordSet, getCommonnessScore } from './lexicon-sources.mjs';
|
||||
import { capWordList } from '../src/engine/word-cap.js';
|
||||
|
||||
// Clean dictionary incl. regional spellings + proper-word allowlist
|
||||
// (same source as src/data/lexicon.js)
|
||||
const cleanWordSet = buildWordSet();
|
||||
|
||||
// Build prefix set for super fast solver pruning (up to 6 letters)
|
||||
const prefixSet = new Set();
|
||||
for (const word of cleanWordSet) {
|
||||
for (let len = 1; len <= Math.min(word.length, 6); len++) {
|
||||
prefixSet.add(word.slice(0, len));
|
||||
}
|
||||
}
|
||||
|
||||
// Function to solve a 20-tile board and return all valid words and paths
|
||||
export function solveBoard(quartets, maxWords = 30) {
|
||||
const tiles = quartets.flat();
|
||||
const foundMap = new Map();
|
||||
|
||||
function search(prefix, usedIndices) {
|
||||
if (prefix.length > 0) {
|
||||
const checkLen = Math.min(prefix.length, 6);
|
||||
if (!prefixSet.has(prefix.slice(0, checkLen))) return;
|
||||
}
|
||||
|
||||
if (prefix.length >= 2 && cleanWordSet.has(prefix)) {
|
||||
const existing = foundMap.get(prefix);
|
||||
// Keep first path found, but a single tile that is a word always wins
|
||||
if (!existing || (usedIndices.length === 1 && existing.length > 1)) {
|
||||
foundMap.set(prefix, [...usedIndices]);
|
||||
}
|
||||
}
|
||||
if (usedIndices.length >= 4) return;
|
||||
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
if (usedIndices.includes(i)) continue;
|
||||
usedIndices.push(i);
|
||||
search(prefix + tiles[i], usedIndices);
|
||||
usedIndices.pop();
|
||||
}
|
||||
}
|
||||
|
||||
search('', []);
|
||||
|
||||
const quartilesList = quartets.map(q => q.join('').toLowerCase());
|
||||
const quartileSet = new Set(quartilesList);
|
||||
|
||||
// Target quartiles are always valid, even if missing from the dictionary
|
||||
quartilesList.forEach((word, qIdx) => {
|
||||
if (!foundMap.has(word)) {
|
||||
foundMap.set(word, [qIdx * 4, qIdx * 4 + 1, qIdx * 4 + 2, qIdx * 4 + 3]);
|
||||
}
|
||||
});
|
||||
|
||||
let words = [];
|
||||
|
||||
for (const [word, path] of foundMap.entries()) {
|
||||
const tileSegments = path.map(idx => tiles[idx]);
|
||||
const tileCount = path.length;
|
||||
let points = tileCount === 4 ? 8 : tileCount === 3 ? 4 : tileCount;
|
||||
const isTargetQuartile = quartileSet.has(word);
|
||||
const isQuartile = tileCount === 4;
|
||||
|
||||
words.push({
|
||||
word,
|
||||
points,
|
||||
tileCount,
|
||||
tileIndices: path,
|
||||
tileSegments,
|
||||
isTargetQuartile,
|
||||
isQuartile
|
||||
});
|
||||
}
|
||||
|
||||
// Sort candidate words:
|
||||
// 1. Target quartiles always first (all 5 guaranteed)
|
||||
// 2. Tile count descending (4-tiles > 3-tiles > 2-tiles > 1-tile)
|
||||
// 3. Commonness score descending (familiar words first)
|
||||
// 4. Word length descending
|
||||
// 5. Alphabetical
|
||||
words.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount;
|
||||
const ca = getCommonnessScore(a.word);
|
||||
const cb = getCommonnessScore(b.word);
|
||||
if (ca !== cb) return cb - ca;
|
||||
if (b.word.length !== a.word.length) return b.word.length - a.word.length;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
|
||||
// Cap words at maxWords (default: 30) while keeping a fair share of 2-tile words.
|
||||
words = capWordList(words, maxWords);
|
||||
|
||||
// Final display sorting: Target quartiles first, then points desc, then alphabetical
|
||||
words.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
|
||||
const totalBaseScore = words.reduce((sum, w) => sum + w.points, 0);
|
||||
const maxPossibleScore = totalBaseScore + 40; // 40 bonus points for all 5 quartiles
|
||||
|
||||
return {
|
||||
tiles,
|
||||
quartets,
|
||||
targetQuartiles: quartilesList,
|
||||
words,
|
||||
wordCount: words.length,
|
||||
quartileCount: 5,
|
||||
totalBaseScore,
|
||||
maxPossibleScore
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Solver module ready. Clean words count:', cleanWordSet.size);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
||||
import { getRecentDailyPuzzles } from '../engine/daily.js';
|
||||
import { sound } from '../engine/audio.js';
|
||||
import { APP_VERSION } from '../engine/version.js';
|
||||
|
||||
export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuzzle, onRevealAll }) {
|
||||
const container = document.createElement('div');
|
||||
@@ -100,10 +101,10 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:12px;">
|
||||
<p style="font-size:0.88rem; color:var(--text-secondary);">
|
||||
Choose from the official daily puzzles or themed challenge packs:
|
||||
Today's puzzle and the last three weeks of dailies:
|
||||
</p>
|
||||
<div class="puzzles-list-grid">
|
||||
${CURATED_PUZZLES.map(p => {
|
||||
${getRecentDailyPuzzles(21).map(p => {
|
||||
const isCurrent = state.currentPuzzle && state.currentPuzzle.id === p.id;
|
||||
const saved = localStorage.getItem(`quartiles_save_${p.id}`);
|
||||
let progressBadge = '';
|
||||
@@ -278,6 +279,8 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
|
||||
Reset Current Puzzle Progress
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="app-version-label" id="settings-app-version">Quartiles v${APP_VERSION}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
37
src/components/UpdateBanner.js
Normal file
37
src/components/UpdateBanner.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { APP_VERSION } from '../engine/version.js';
|
||||
|
||||
/**
|
||||
* Persistent banner asking the user to reload when a newer version is deployed.
|
||||
* Progress is saved to localStorage continuously, so reloading is safe.
|
||||
*/
|
||||
export function createUpdateBanner() {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'update-banner';
|
||||
element.id = 'update-banner';
|
||||
element.setAttribute('role', 'status');
|
||||
element.setAttribute('aria-live', 'polite');
|
||||
element.hidden = true;
|
||||
|
||||
function show(latestVersion) {
|
||||
element.innerHTML = `
|
||||
<div class="update-banner-text">
|
||||
<strong>New version available</strong>
|
||||
<span>v${APP_VERSION} → v${latestVersion}</span>
|
||||
</div>
|
||||
<button class="update-banner-reload" id="btn-update-reload">Reload</button>
|
||||
<button class="update-banner-dismiss" id="btn-update-dismiss" aria-label="Dismiss">✕</button>
|
||||
`;
|
||||
element.hidden = false;
|
||||
requestAnimationFrame(() => element.classList.add('visible'));
|
||||
|
||||
element.querySelector('#btn-update-reload').addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
element.querySelector('#btn-update-dismiss').addEventListener('click', () => {
|
||||
element.classList.remove('visible');
|
||||
setTimeout(() => { element.hidden = true; }, 250);
|
||||
});
|
||||
}
|
||||
|
||||
return { element, show };
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
9510
src/data/puzzles.js
9510
src/data/puzzles.js
File diff suppressed because it is too large
Load Diff
51
src/engine/daily.js
Normal file
51
src/engine/daily.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { generateDailyPuzzle, getDailyPuzzleMeta } from './generator.js';
|
||||
|
||||
const DAILY_ID_PREFIX = 'daily-';
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const dailyCache = new Map();
|
||||
|
||||
/** Local date as YYYY-MM-DD */
|
||||
export function toDateString(date = new Date()) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function isDailyId(id) {
|
||||
return typeof id === 'string' && id.startsWith(DAILY_ID_PREFIX);
|
||||
}
|
||||
|
||||
/** Deterministic generated puzzle for a date (same board for everyone) */
|
||||
export function getDailyPuzzle(dateStr) {
|
||||
if (!dailyCache.has(dateStr)) {
|
||||
dailyCache.set(dateStr, generateDailyPuzzle(dateStr));
|
||||
}
|
||||
return dailyCache.get(dateStr);
|
||||
}
|
||||
|
||||
/** Resolve a daily puzzle from "daily-YYYY-MM-DD" or "YYYY-MM-DD" */
|
||||
export function resolveBuiltInPuzzle(idOrDate) {
|
||||
if (typeof idOrDate !== 'string') return null;
|
||||
const dateStr = isDailyId(idOrDate) ? idOrDate.slice(DAILY_ID_PREFIX.length) : idOrDate;
|
||||
return DATE_RE.test(dateStr) ? getDailyPuzzle(dateStr) : null;
|
||||
}
|
||||
|
||||
/** True for puzzles that can be rebuilt from their id (no need to persist the full object) */
|
||||
export function isBuiltInPuzzleId(id) {
|
||||
return isDailyId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily puzzle summaries ({ id, date, theme, difficulty }) for the last `days`
|
||||
* days, today first. Never includes future dates. Doesn't run the solver.
|
||||
*/
|
||||
export function getRecentDailyPuzzles(days = 21) {
|
||||
const list = [];
|
||||
const cursor = new Date();
|
||||
for (let i = 0; i < days; i++) {
|
||||
list.push(getDailyPuzzleMeta(toDateString(cursor)));
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -1,101 +1,91 @@
|
||||
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
|
||||
import { solveBoard } from './solver.js';
|
||||
|
||||
/**
|
||||
* Generates a random valid Quartiles puzzle from the target word pool
|
||||
*/
|
||||
/**
|
||||
* Generates a random valid Quartiles puzzle from the target word pool
|
||||
* Guarantees:
|
||||
* - 20 strictly unique tiles (no duplicates anywhere on board)
|
||||
* - All tiles are at least 2 characters (never 1 letter)
|
||||
* - Anti-repetition: tracks recently used words in localStorage across games
|
||||
* - Highly varied and random puzzles
|
||||
*/
|
||||
export function generateRandomPuzzle(seed) {
|
||||
// Load recently used target words to prevent repetition across games
|
||||
let recentWords = [];
|
||||
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()));
|
||||
// Variety limits per board, so puzzles don't feel samey
|
||||
const MAX_SUFFIX_WORDS = 1; // words ending in -tion / -sion
|
||||
const MAX_LONG_WORDS = 2; // words with 11+ letters
|
||||
const LONG_WORD_LENGTH = 11;
|
||||
const SUFFIX_RE = /(tion|sion)$/;
|
||||
|
||||
// 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];
|
||||
/** Deterministic PRNG (mulberry32) seeded from a string via FNV-1a */
|
||||
export function createSeededRandom(seed) {
|
||||
let h = 0x811c9dc5;
|
||||
const str = String(seed);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
let a = h >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to shuffle an array
|
||||
function shuffle(arr) {
|
||||
function shuffle(arr, rng) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to find 5 words with 0 tile collisions (strictly 20 unique tiles)
|
||||
let selectedWords = null;
|
||||
const attemptsPools = [shuffle(poolCandidate), shuffle(TARGET_WORDS_POOL), shuffle(TARGET_WORDS_POOL)];
|
||||
|
||||
for (const pool of attemptsPools) {
|
||||
/**
|
||||
* Greedily picks 5 words with 20 unique tiles from a shuffled pool while
|
||||
* respecting the variety limits. Returns null if it can't fill the board.
|
||||
*/
|
||||
function pickWords(pool, { enforceVariety = true } = {}) {
|
||||
const picked = [];
|
||||
const usedTiles = new Set();
|
||||
let suffixCount = 0;
|
||||
let longCount = 0;
|
||||
|
||||
for (const item of pool) {
|
||||
if (!item.tiles || item.tiles.length !== 4) continue;
|
||||
// Ensure no 1-letter tiles
|
||||
if (!item.tiles.every(t => t && t.length >= 2)) continue;
|
||||
if (item.tiles.some(t => usedTiles.has(t.toLowerCase()))) continue;
|
||||
|
||||
const isSuffix = SUFFIX_RE.test(item.word);
|
||||
const isLong = item.word.length >= LONG_WORD_LENGTH;
|
||||
if (enforceVariety) {
|
||||
if (isSuffix && suffixCount >= MAX_SUFFIX_WORDS) continue;
|
||||
if (isLong && longCount >= MAX_LONG_WORDS) continue;
|
||||
}
|
||||
|
||||
// Check collision with already used tiles
|
||||
const hasCollision = item.tiles.some(t => usedTiles.has(t.toLowerCase()));
|
||||
if (!hasCollision) {
|
||||
picked.push(item);
|
||||
item.tiles.forEach(t => usedTiles.add(t.toLowerCase()));
|
||||
if (picked.length === 5) {
|
||||
selectedWords = picked;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectedWords && selectedWords.length === 5) break;
|
||||
if (isSuffix) suffixCount++;
|
||||
if (isLong) longCount++;
|
||||
if (picked.length === 5) return picked;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectWords(candidatePool, rng) {
|
||||
const attempts = [
|
||||
() => pickWords(shuffle(candidatePool, rng)),
|
||||
() => pickWords(shuffle(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
|
||||
if (!selectedWords || selectedWords.length < 5) {
|
||||
selectedWords = shuffle(TARGET_WORDS_POOL).slice(0, 5);
|
||||
}
|
||||
|
||||
// Save selected words into recent words history (keep up to last 50 words)
|
||||
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
|
||||
}
|
||||
return shuffle(TARGET_WORDS_POOL, rng).slice(0, 5);
|
||||
}
|
||||
|
||||
function buildPuzzle(selectedWords, meta) {
|
||||
const quartets = selectedWords.map(w => w.tiles.map(t => t.toLowerCase().trim()));
|
||||
const solved = solveBoard(quartets, 30);
|
||||
|
||||
const id = 'custom-' + Date.now();
|
||||
const theme = selectedWords[0].theme ? `${selectedWords[0].theme.toUpperCase()} & BEYOND` : 'Unlimited Challenge';
|
||||
|
||||
return {
|
||||
id,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
title: `Unlimited Puzzle #${Math.floor(Math.random() * 9000 + 1000)}`,
|
||||
theme,
|
||||
difficulty: 'Random',
|
||||
...meta,
|
||||
quartets,
|
||||
tiles: solved.tiles,
|
||||
targetQuartiles: solved.targetQuartiles,
|
||||
@@ -110,6 +100,97 @@ export function generateRandomPuzzle(seed) {
|
||||
};
|
||||
}
|
||||
|
||||
function themeLabel(selectedWords) {
|
||||
const theme = selectedWords[0]?.theme;
|
||||
return theme ? `${theme.charAt(0).toUpperCase()}${theme.slice(1)} & Beyond` : 'Unlimited Challenge';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random valid Quartiles puzzle from the target word pool
|
||||
* Guarantees:
|
||||
* - 20 strictly unique tiles (no duplicates anywhere on board)
|
||||
* - All tiles are at least 2 characters (never 1 letter)
|
||||
* - Variety: at most one -tion/-sion answer and two 11+ letter answers
|
||||
* - Anti-repetition: tracks recently used words in localStorage across games
|
||||
* (skipped when a seed is given, so seeded puzzles stay deterministic)
|
||||
*/
|
||||
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 = [];
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return buildPuzzle(selectedWords, {
|
||||
id: 'custom-' + Date.now(),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
title: `Unlimited Puzzle #${Math.floor(rng() * 9000 + 1000)}`,
|
||||
theme: themeLabel(selectedWords),
|
||||
difficulty: 'Random'
|
||||
});
|
||||
}
|
||||
|
||||
function selectDaily(dateStr) {
|
||||
const rng = createSeededRandom(`quartiles-daily-${dateStr}`);
|
||||
const selectedWords = selectWords(TARGET_WORDS_POOL, rng);
|
||||
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 = {
|
||||
id: `daily-${dateStr}`,
|
||||
date: dateStr,
|
||||
title: `Daily Puzzle — ${label}`,
|
||||
theme: themeLabel(selectedWords).replace(' & Beyond', ' Mix'),
|
||||
difficulty: 'Normal'
|
||||
};
|
||||
return { selectedWords, meta };
|
||||
}
|
||||
|
||||
/** Lightweight daily puzzle info (no solving) for lists like the calendar */
|
||||
export function getDailyPuzzleMeta(dateStr) {
|
||||
return selectDaily(dateStr).meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic daily puzzle for a YYYY-MM-DD date. Every player gets the
|
||||
* same board for the same date.
|
||||
*/
|
||||
export function generateDailyPuzzle(dateStr) {
|
||||
const { selectedWords, meta } = selectDaily(dateStr);
|
||||
return buildPuzzle(selectedWords, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and creates a custom puzzle from user-provided quartets
|
||||
* Expects array of 5 quartets: [[t1,t2,t3,t4], [t5,t6,t7,t8], ...]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DICTIONARY_SET, PREFIX_SET } from '../data/lexicon.js';
|
||||
import { capWordList } from './word-cap.js';
|
||||
import { DICTIONARY_SET, PREFIX_SET, getCommonnessScore } from '../data/lexicon.js';
|
||||
import { capWordList, compareCandidates } from './word-cap.js';
|
||||
import { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo } from './constants.js';
|
||||
|
||||
export { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo };
|
||||
@@ -66,17 +66,10 @@ export function solveBoard(quartets, maxWords = 30) {
|
||||
});
|
||||
}
|
||||
|
||||
// Sort candidate words:
|
||||
// 1. Target quartiles always first (all 5 guaranteed)
|
||||
// 2. Tile count descending (4-tiles > 3-tiles > 2-tiles > 1-tile)
|
||||
// 3. Word length descending
|
||||
// 4. Alphabetical
|
||||
words.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount;
|
||||
if (b.word.length !== a.word.length) return b.word.length - a.word.length;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
// Rank candidates: target quartiles first, then familiar words, then tile
|
||||
// count (see compareCandidates). Shorter words win ties so the list isn't
|
||||
// dominated by the longest possible combos.
|
||||
words.sort((a, b) => compareCandidates(a, b, getCommonnessScore));
|
||||
|
||||
// Cap at maxWords (default: 30) while keeping a fair share of 2-tile words.
|
||||
// Valid words beyond the cap are still accepted in play as bonus words.
|
||||
|
||||
44
src/engine/version.js
Normal file
44
src/engine/version.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import versionInfo from '../version.json';
|
||||
|
||||
/** Version baked into this bundle at build time */
|
||||
export const APP_VERSION = versionInfo.version;
|
||||
|
||||
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Fetches the latest deployed version, bypassing HTTP and service worker caches */
|
||||
export async function fetchLatestVersion() {
|
||||
const res = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`version.json request failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data?.version || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls /version.json (on start, every 5 minutes, and whenever the tab
|
||||
* becomes visible). Calls onUpdateAvailable(latestVersion) once when the
|
||||
* deployed version differs from APP_VERSION.
|
||||
*/
|
||||
export function startVersionCheck(onUpdateAvailable) {
|
||||
let notified = false;
|
||||
|
||||
async function check() {
|
||||
if (notified || !navigator.onLine) return;
|
||||
try {
|
||||
const latest = await fetchLatestVersion();
|
||||
if (latest && latest !== APP_VERSION) {
|
||||
notified = true;
|
||||
onUpdateAvailable(latest);
|
||||
}
|
||||
} catch (err) {
|
||||
// Offline or file missing: try again on the next tick
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
const timer = setInterval(check, CHECK_INTERVAL_MS);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') check();
|
||||
});
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -1,3 +1,27 @@
|
||||
/**
|
||||
* Ranking for solver candidates (used by src/engine/solver.js).
|
||||
*
|
||||
* 1. Target quartiles first.
|
||||
* 2. Familiar words (commonness >= 2) before rare ones, so long obscure
|
||||
* combos can't crowd out everyday words.
|
||||
* 3. Higher commonness tier.
|
||||
* 4. More tiles (worth more points).
|
||||
* 5. Shorter word (avoids a bias toward the longest possible combos).
|
||||
* 6. Alphabetical.
|
||||
*/
|
||||
export function compareCandidates(a, b, getCommonness) {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
const ca = getCommonness(a.word);
|
||||
const cb = getCommonness(b.word);
|
||||
const fa = ca >= 2 ? 1 : 0;
|
||||
const fb = cb >= 2 ? 1 : 0;
|
||||
if (fa !== fb) return fb - fa;
|
||||
if (ca !== cb) return cb - ca;
|
||||
if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount;
|
||||
if (a.word.length !== b.word.length) return a.word.length - b.word.length;
|
||||
return a.word.localeCompare(b.word);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps a sorted candidate word list to `maxWords` entries.
|
||||
*
|
||||
|
||||
35
src/main.js
35
src/main.js
@@ -3,7 +3,7 @@ import './styles/board.css';
|
||||
import './styles/components.css';
|
||||
|
||||
import { gameState } from './state/game-state.js';
|
||||
import { CURATED_PUZZLES } from './data/puzzles.js';
|
||||
import { getDailyPuzzle, resolveBuiltInPuzzle, toDateString } from './engine/daily.js';
|
||||
import { sound } from './engine/audio.js';
|
||||
import { fireConfetti } from './engine/confetti.js';
|
||||
|
||||
@@ -15,9 +15,13 @@ import { createActionBar } from './components/ActionBar.js';
|
||||
import { createWordsDrawer } from './components/WordsDrawer.js';
|
||||
import { createModals } from './components/Modals.js';
|
||||
import { createToastContainer } from './components/Toast.js';
|
||||
import { createUpdateBanner } from './components/UpdateBanner.js';
|
||||
import { APP_VERSION, startVersionCheck } from './engine/version.js';
|
||||
|
||||
// PWA Service Worker Registration
|
||||
// PWA Service Worker Registration (production only: in dev it would cache
|
||||
// unhashed modules and serve stale code after a reload)
|
||||
if ('serviceWorker' in navigator && window.location.protocol.startsWith('http')) {
|
||||
if (import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
@@ -28,6 +32,9 @@ if ('serviceWorker' in navigator && window.location.protocol.startsWith('http'))
|
||||
console.warn('PWA Service Worker registration error:', err);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
|
||||
}
|
||||
}
|
||||
|
||||
async function initApp() {
|
||||
@@ -178,6 +185,18 @@ async function initApp() {
|
||||
});
|
||||
appContainer.appendChild(actionBar.element);
|
||||
|
||||
// Version label
|
||||
const versionLabel = document.createElement('div');
|
||||
versionLabel.className = 'app-version-label';
|
||||
versionLabel.id = 'app-version-label';
|
||||
versionLabel.textContent = `v${APP_VERSION}`;
|
||||
appContainer.appendChild(versionLabel);
|
||||
|
||||
// Prompt to reload when a newer version has been deployed
|
||||
const updateBanner = createUpdateBanner();
|
||||
document.body.appendChild(updateBanner.element);
|
||||
startVersionCheck(latest => updateBanner.show(latest));
|
||||
|
||||
// Word submission handler
|
||||
function handleWordSubmission() {
|
||||
const prevRank = gameState.getRank();
|
||||
@@ -364,18 +383,18 @@ async function initApp() {
|
||||
console.warn('Failed to restore custom active puzzle:', e);
|
||||
}
|
||||
} else {
|
||||
const matched = CURATED_PUZZLES.find(p => p.id === savedActivePuzzleId || p.date === savedActivePuzzleId);
|
||||
if (matched) {
|
||||
const matched = resolveBuiltInPuzzle(savedActivePuzzleId);
|
||||
// A daily opened on a previous day yields to today's daily
|
||||
const activeDay = localStorage.getItem('quartiles_active_day');
|
||||
if (matched && activeDay === toDateString()) {
|
||||
gameState.loadPuzzle(matched);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Load today's puzzle or default
|
||||
const todayStr = gameState.getTodayDateString();
|
||||
const todayPuzzle = CURATED_PUZZLES.find(p => p.date === todayStr) || CURATED_PUZZLES[0];
|
||||
gameState.loadPuzzle(todayPuzzle);
|
||||
// Fallback: Load today's generated daily puzzle
|
||||
gameState.loadPuzzle(getDailyPuzzle(toDateString()));
|
||||
}
|
||||
|
||||
// Start application when DOM is ready
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
||||
import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '../engine/constants.js';
|
||||
import { isAcceptedWord } from '../data/lexicon.js';
|
||||
import { resolveBuiltInPuzzle, isBuiltInPuzzleId, getDailyPuzzle, toDateString } from '../engine/daily.js';
|
||||
|
||||
// Identifies the exact board, so saves from an older version of a puzzle id are ignored
|
||||
function boardSignature(puzzle) {
|
||||
return (puzzle?.tiles || []).join('|');
|
||||
}
|
||||
|
||||
class GameStateManager {
|
||||
constructor() {
|
||||
@@ -43,7 +48,7 @@ class GameStateManager {
|
||||
loadPuzzle(puzzleOrId) {
|
||||
let puzzle = puzzleOrId;
|
||||
if (typeof puzzleOrId === 'string') {
|
||||
puzzle = CURATED_PUZZLES.find(p => p.id === puzzleOrId || p.date === puzzleOrId) || CURATED_PUZZLES[0];
|
||||
puzzle = resolveBuiltInPuzzle(puzzleOrId) || getDailyPuzzle(toDateString());
|
||||
}
|
||||
|
||||
this.currentPuzzle = puzzle;
|
||||
@@ -114,8 +119,9 @@ class GameStateManager {
|
||||
if (!this.currentPuzzle) return;
|
||||
try {
|
||||
localStorage.setItem('quartiles_active_puzzle_id', this.currentPuzzle.id);
|
||||
const isCurated = CURATED_PUZZLES.some(p => p.id === this.currentPuzzle.id);
|
||||
if (!isCurated) {
|
||||
localStorage.setItem('quartiles_active_day', this.getTodayDateString());
|
||||
const isBuiltIn = isBuiltInPuzzleId(this.currentPuzzle.id);
|
||||
if (!isBuiltIn) {
|
||||
localStorage.setItem(`quartiles_custom_puzzle_${this.currentPuzzle.id}`, JSON.stringify(this.currentPuzzle));
|
||||
localStorage.setItem('quartiles_active_is_custom', 'true');
|
||||
} else {
|
||||
@@ -133,6 +139,7 @@ class GameStateManager {
|
||||
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
|
||||
const payload = {
|
||||
puzzleId: this.currentPuzzle.id,
|
||||
boardSignature: boardSignature(this.currentPuzzle),
|
||||
foundWords: Array.from(this.foundWords.entries()),
|
||||
score: this.score,
|
||||
bonus: this.quartilesBonusAwarded,
|
||||
@@ -163,6 +170,22 @@ class GameStateManager {
|
||||
const saved = localStorage.getItem(saveKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
// Ignore saves made for a different board under the same id
|
||||
if (parsed && parsed.boardSignature && parsed.boardSignature !== boardSignature(this.currentPuzzle)) {
|
||||
localStorage.removeItem(saveKey);
|
||||
return false;
|
||||
}
|
||||
// Legacy saves (no signature): drop if they used tiles not on this board
|
||||
if (parsed && !parsed.boardSignature && Array.isArray(parsed.foundWords)) {
|
||||
const boardTileSet = new Set(this.currentPuzzle.tiles.map(t => t.toLowerCase()));
|
||||
const stale = parsed.foundWords.some(([, entry]) =>
|
||||
Array.isArray(entry?.tileSegments) && entry.tileSegments.some(t => !boardTileSet.has(String(t).toLowerCase()))
|
||||
);
|
||||
if (stale) {
|
||||
localStorage.removeItem(saveKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (parsed) {
|
||||
if (Array.isArray(parsed.foundWords)) {
|
||||
const validWordsMap = new Map(this.currentPuzzle.words.map(w => [w.word.toLowerCase(), w]));
|
||||
|
||||
@@ -845,3 +845,86 @@
|
||||
.btn-primary-modal:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
UPDATE BANNER & VERSION LABEL
|
||||
========================================================================== */
|
||||
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
transform: translate(-50%, 24px);
|
||||
opacity: 0;
|
||||
z-index: 1100;
|
||||
width: min(440px, calc(100vw - 24px));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 10px 10px 16px;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
|
||||
transition: transform 0.3s var(--ease-spring), opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.update-banner.visible {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.update-banner-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.update-banner-text span {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.update-banner-reload {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-submit-gradient);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
box-shadow: 0 4px 12px var(--accent-submit-glow);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.update-banner-reload:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.update-banner-dismiss {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.update-banner-dismiss:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.app-version-label {
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
3
src/version.json
Normal file
3
src/version.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "1.0.1"
|
||||
}
|
||||
32
vite.config.js
Normal file
32
vite.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import fs from 'node:fs';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const VERSION_FILE = new URL('./src/version.json', import.meta.url);
|
||||
|
||||
// src/version.json is imported by the app (baked into the bundle) and also
|
||||
// published as /version.json, which running clients poll to detect new deploys.
|
||||
function versionFilePlugin() {
|
||||
return {
|
||||
name: 'quartiles-version-file',
|
||||
// Dev: always serve the current file contents, uncached
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/version.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(fs.readFileSync(VERSION_FILE, 'utf8'));
|
||||
});
|
||||
},
|
||||
// Build: emit dist/version.json
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'version.json',
|
||||
source: fs.readFileSync(VERSION_FILE, 'utf8')
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [versionFilePlugin()]
|
||||
});
|
||||
Reference in New Issue
Block a user