Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ npm run dev

`npm run generate:daily-words` refreshes `src/data/generated-daily-puzzles.ts` from `src/data/daily-puzzles.json` and the MIT-licensed `wordlist-english` npm package. The generator treats the configured word source as the playable dictionary: it enumerates every 1-4 tile string that can be built from each dated board, keeps every match in that source, always includes the five target quartet words, and filters a small explicit blocklist for inappropriate entries.

Generation fails unless every daily board has exactly five quartet rows of four tiles, every tile text is unique within that board, every tile is 2–4 letters long, and exactly the five target four-tile words are valid. This prevents duplicate tiles, one-letter fragments, overlong fragments, and accidental “extra quartets.”
Generation fails unless every daily board has exactly five quartet rows of four tiles, every tile text is unique within that board, every configured quartet word is unique, every tile is 2–4 letters long, and exactly the five target four-tile tile-id paths are valid. This prevents duplicate tiles, duplicate target-word segmentations, one-letter fragments, overlong fragments, and accidental “extra quartets.”

Add one or more missing daily puzzles manually with:

Expand Down
37 changes: 26 additions & 11 deletions scripts/add-daily-puzzle.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import wordlistEnglish from 'wordlist-english'
import { validateSourcePuzzle } from './daily-puzzle-source.mjs'

const dailyPuzzlesPath = fileURLToPath(new URL('../src/data/daily-puzzles.json', import.meta.url))
const minTileLength = 2
Expand All @@ -10,6 +11,7 @@ const targetQuartetCount = 5
const maxAttempts = 10000
const datePattern = /^\d{4}-\d{2}-\d{2}$/
const normalize = (word) => word.toLowerCase().replace(/[^a-z]/g, '')
const pathSignature = (tileIds) => tileIds.join(',')
const fullWordSet = new Set(wordlistEnglish.english.map(normalize).filter((word) => word.length >= 3))
const commonWords = [...new Set(wordlistEnglish['english/10'].map(normalize))].filter(
(word) => word.length >= 8 && word.length <= 13 && /^[a-z]+$/.test(word),
Expand Down Expand Up @@ -111,15 +113,15 @@ const candidateWords = [...optionsByWord.keys()]

const sample = (items, random) => items[Math.floor(random() * items.length)]

const fourTileWordsForBoard = (tiles, requiredWords) => {
const fourTilePathsForBoard = (tiles, requiredWords) => {
const sourceWords = new Set(fullWordSet)
requiredWords.forEach((word) => sourceWords.add(word))
const found = new Set()
const found = []

const search = (prefix, usedTileIds) => {
if (usedTileIds.size === maxTilesPerWord) {
const search = (prefix, usedTileIds, tileIds) => {
if (tileIds.length === maxTilesPerWord) {
if (sourceWords.has(prefix)) {
found.add(prefix)
found.push({ word: prefix, tileIds: [...tileIds], signature: pathSignature(tileIds) })
}
return
}
Expand All @@ -130,20 +132,31 @@ const fourTileWordsForBoard = (tiles, requiredWords) => {
}

usedTileIds.add(tileId)
search(prefix + tiles[tileId], usedTileIds)
search(prefix + tiles[tileId], usedTileIds, [...tileIds, tileId])
usedTileIds.delete(tileId)
}
}

search('', new Set())
return [...found].sort((left, right) => left.localeCompare(right))
search('', new Set(), [])
return found.sort((left, right) => left.word.localeCompare(right.word) || left.signature.localeCompare(right.signature))
}

const isExactQuartetBoard = (quartets) => {
const targets = quartets.map((quartet) => quartet.join('')).sort((left, right) => left.localeCompare(right))
const tiles = quartets.flat()
const fourTileWords = fourTileWordsForBoard(tiles, targets)
return fourTileWords.length === targetQuartetCount && fourTileWords.every((word, index) => word === targets[index])
const targets = quartets.map((quartet, quartetIndex) => {
const tileIds = quartet.map((_, tileIndex) => quartetIndex * maxTilesPerWord + tileIndex)
return { word: quartet.join(''), tileIds, signature: pathSignature(tileIds) }
})
const fourTilePaths = fourTilePathsForBoard(
tiles,
targets.map((target) => target.word),
)
const targetSignatures = new Set(targets.map((target) => target.signature))

return (
fourTilePaths.length === targetQuartetCount &&
fourTilePaths.every((path) => targetSignatures.has(path.signature))
)
}

const generatePuzzleForDate = (date) => {
Expand Down Expand Up @@ -196,6 +209,7 @@ const generatePuzzleForDate = (date) => {

const dateSelection = parseDateArg()
const dailyPuzzles = JSON.parse(readFileSync(dailyPuzzlesPath, 'utf8'))
dailyPuzzles.forEach(validateSourcePuzzle)
const existingDates = new Set(dailyPuzzles.map((puzzle) => puzzle.date))
const datesToAdd =
dateSelection.mode === 'catch-up'
Expand Down Expand Up @@ -225,6 +239,7 @@ if (datesToAdd.length === 0) {
const addedPuzzles = []
for (const date of datesToAdd) {
const puzzle = generatePuzzleForDate(date)
validateSourcePuzzle(puzzle)
dailyPuzzles.push(puzzle)
existingDates.add(date)
addedPuzzles.push(puzzle)
Expand Down
54 changes: 54 additions & 0 deletions scripts/daily-puzzle-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const minimumTileLength = 2
const maximumTileLength = 4
const maxTilesPerWord = 4
const targetQuartetCount = 5
const datePattern = /^\d{4}-\d{2}-\d{2}$/

export const normalize = (value) => value.toLowerCase().replace(/[^a-z]/g, '')

const pathSignature = (tileIds) => tileIds.join(',')
const formatPath = (path, tiles) => `${path.word} [${path.signature}] (${path.tileIds.map((tileId) => tiles[tileId]).join('+')})`

export const validateSourcePuzzle = (sourcePuzzle) => {
if (!datePattern.test(sourcePuzzle.date)) {
throw new Error(`Daily puzzle date must be YYYY-MM-DD: ${sourcePuzzle.date}`)
}

const quartets = sourcePuzzle.quartets
if (!Array.isArray(quartets) || quartets.length !== targetQuartetCount || quartets.some((quartet) => quartet.length !== maxTilesPerWord)) {
throw new Error(`${sourcePuzzle.date}: daily source must contain exactly ${targetQuartetCount} quartets of ${maxTilesPerWord} tiles each.`)
}

const normalizedQuartets = quartets.map((quartet) => quartet.map(normalize))
const tiles = normalizedQuartets.flat()
const duplicateTiles = tiles.filter((tile, index) => tiles.indexOf(tile) !== index)
const invalidLengthTiles = tiles.filter((tile) => tile.length < minimumTileLength || tile.length > maximumTileLength)

if (invalidLengthTiles.length > 0 || duplicateTiles.length > 0) {
throw new Error(
`${sourcePuzzle.date}: source tiles must be unique and between ${minimumTileLength} and ${maximumTileLength} letters long. Invalid length: ${[
...new Set(invalidLengthTiles),
].join(', ') || '(none)'}. Duplicate: ${[...new Set(duplicateTiles)].join(', ') || '(none)'}.`,
)
}

const targetQuartetPaths = normalizedQuartets.map((quartet, quartetIndex) => {
const tileIds = quartet.map((_, tileIndex) => quartetIndex * maxTilesPerWord + tileIndex)
return { word: quartet.join(''), tileIds, signature: pathSignature(tileIds) }
})
const pathsByWord = new Map()
for (const path of targetQuartetPaths) {
pathsByWord.set(path.word, [...(pathsByWord.get(path.word) ?? []), path])
}
const duplicateQuartetPaths = [...pathsByWord.values()].filter((paths) => paths.length > 1).flat()

if (duplicateQuartetPaths.length > 0) {
throw new Error(
`${sourcePuzzle.date}: configured quartet words must be unique. Duplicate: ${duplicateQuartetPaths
.map((path) => formatPath(path, tiles))
.join('; ')}.`,
)
}

return normalizedQuartets
}
21 changes: 21 additions & 0 deletions scripts/daily-puzzle-source.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { validateSourcePuzzle } from './daily-puzzle-source.mjs'

const duplicateConfiguredQuartetWordPuzzle = {
date: '2099-01-01',
quartets: [
['ab', 'cd', 'ef', 'ghij'],
['abc', 'de', 'fg', 'hij'],
['kl', 'mn', 'op', 'qr'],
['st', 'uv', 'wx', 'yz'],
['za', 'yb', 'xc', 'wd'],
],
}

describe('daily puzzle source validation', () => {
it('rejects duplicate configured quartet words even when segmentations use different exact paths', () => {
expect(() => validateSourcePuzzle(duplicateConfiguredQuartetWordPuzzle)).toThrow(
/2099-01-01: configured quartet words must be unique.*abcdefghij \[0,1,2,3\] \(ab\+cd\+ef\+ghij\).*abcdefghij \[4,5,6,7\] \(abc\+de\+fg\+hij\)/,
)
})
})
71 changes: 25 additions & 46 deletions scripts/generate-daily-words.mjs
Original file line number Diff line number Diff line change
@@ -1,49 +1,22 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import wordlistEnglish from 'wordlist-english'
import { normalize, validateSourcePuzzle } from './daily-puzzle-source.mjs'

const minimumWordLength = 3
const minimumTileLength = 2
const maximumTileLength = 4
const maxTilesPerWord = 4
const targetQuartetCount = 5
const blockedWords = new Set(['wop', 'wops'])

const dailyPuzzlesPath = fileURLToPath(new URL('../src/data/daily-puzzles.json', import.meta.url))
const outputPath = fileURLToPath(new URL('../src/data/generated-daily-puzzles.ts', import.meta.url))

const normalize = (value) => value.toLowerCase().replace(/[^a-z]/g, '')
const datePattern = /^\d{4}-\d{2}-\d{2}$/
const pathSignature = (tileIds) => tileIds.join(',')
const formatPath = (path, tiles) => `${path.word} [${path.signature}] (${path.tileIds.map((tileId) => tiles[tileId]).join('+')})`
const dailyPuzzles = JSON.parse(readFileSync(dailyPuzzlesPath, 'utf8'))
const sourceWords = wordlistEnglish.english
.map(normalize)
.filter((word) => word.length >= minimumWordLength && !blockedWords.has(word))

const validateSourcePuzzle = (sourcePuzzle) => {
if (!datePattern.test(sourcePuzzle.date)) {
throw new Error(`Daily puzzle date must be YYYY-MM-DD: ${sourcePuzzle.date}`)
}

const quartets = sourcePuzzle.quartets
if (!Array.isArray(quartets) || quartets.length !== targetQuartetCount || quartets.some((quartet) => quartet.length !== maxTilesPerWord)) {
throw new Error(`${sourcePuzzle.date}: daily source must contain exactly ${targetQuartetCount} quartets of ${maxTilesPerWord} tiles each.`)
}

const tiles = quartets.flat().map(normalize)
const duplicateTiles = tiles.filter((tile, index) => tiles.indexOf(tile) !== index)
const invalidLengthTiles = tiles.filter((tile) => tile.length < minimumTileLength || tile.length > maximumTileLength)

if (invalidLengthTiles.length > 0 || duplicateTiles.length > 0) {
throw new Error(
`${sourcePuzzle.date}: source tiles must be unique and between ${minimumTileLength} and ${maximumTileLength} letters long. Invalid length: ${[
...new Set(invalidLengthTiles),
].join(', ') || '(none)'}. Duplicate: ${[...new Set(duplicateTiles)].join(', ') || '(none)'}.`,
)
}

return quartets.map((quartet) => quartet.map(normalize))
}

const findConstructibleWords = (tiles) => {
const constructibleWords = new Set()
const search = (prefix, usedTileIds) => {
Expand All @@ -70,14 +43,14 @@ const findConstructibleWords = (tiles) => {
return constructibleWords
}

const findFourTileWords = (tiles, generatedWords) => {
const findFourTileWordPaths = (tiles, generatedWords) => {
const generatedWordSet = new Set(generatedWords)
const fourTileWords = new Set()
const fourTileWordPaths = []

const searchFourTiles = (prefix, usedTileIds) => {
if (usedTileIds.size === maxTilesPerWord) {
const searchFourTiles = (prefix, usedTileIds, tileIds) => {
if (tileIds.length === maxTilesPerWord) {
if (generatedWordSet.has(prefix)) {
fourTileWords.add(prefix)
fourTileWordPaths.push({ word: prefix, tileIds: [...tileIds], signature: pathSignature(tileIds) })
}
return
}
Expand All @@ -88,13 +61,15 @@ const findFourTileWords = (tiles, generatedWords) => {
}

usedTileIds.add(tileId)
searchFourTiles(`${prefix}${tiles[tileId]}`, usedTileIds)
searchFourTiles(`${prefix}${tiles[tileId]}`, usedTileIds, [...tileIds, tileId])
usedTileIds.delete(tileId)
}
}

searchFourTiles('', new Set())
return [...fourTileWords].sort((left, right) => left.localeCompare(right))
searchFourTiles('', new Set(), [])
return fourTileWordPaths.sort(
(left, right) => left.word.localeCompare(right.word) || left.signature.localeCompare(right.signature),
)
}

const buildGeneratedPuzzle = (sourcePuzzle) => {
Expand All @@ -105,17 +80,21 @@ const buildGeneratedPuzzle = (sourcePuzzle) => {
const generatedWords = [...new Set([...sourceWords, ...requiredQuartets])]
.filter((word) => constructibleWords.has(word))
.sort((left, right) => left.localeCompare(right))
const fourTileWords = findFourTileWords(tiles, generatedWords)
const requiredQuartetSet = new Set(requiredQuartets)
const fourTileWordSet = new Set(fourTileWords)
const extraQuartets = fourTileWords.filter((word) => !requiredQuartetSet.has(word))
const missingQuartets = requiredQuartets.filter((word) => !fourTileWordSet.has(word))
const fourTileWordPaths = findFourTileWordPaths(tiles, generatedWords)
const targetQuartetPaths = quartets.map((quartet, quartetIndex) => {
const tileIds = quartet.map((_, tileIndex) => quartetIndex * maxTilesPerWord + tileIndex)
return { word: quartet.join(''), tileIds, signature: pathSignature(tileIds) }
})
const targetPathSignatures = new Set(targetQuartetPaths.map((path) => path.signature))
const fourTilePathSignatures = new Set(fourTileWordPaths.map((path) => path.signature))
const extraQuartetPaths = fourTileWordPaths.filter((path) => !targetPathSignatures.has(path.signature))
const missingQuartetPaths = targetQuartetPaths.filter((path) => !fourTilePathSignatures.has(path.signature))
const hasExactTargetQuartets =
fourTileWords.length === requiredQuartets.length && extraQuartets.length === 0 && missingQuartets.length === 0
fourTileWordPaths.length === targetQuartetPaths.length && extraQuartetPaths.length === 0 && missingQuartetPaths.length === 0

if (!hasExactTargetQuartets) {
throw new Error(
`${sourcePuzzle.date}: invalid quartet board. Target quartets: ${requiredQuartets.join(', ')}. Valid four-tile words: ${fourTileWords.join(', ')}. Extra: ${extraQuartets.join(', ') || '(none)'}. Missing: ${missingQuartets.join(', ') || '(none)'}.`,
`${sourcePuzzle.date}: invalid quartet board. Target quartet paths: ${targetQuartetPaths.map((path) => formatPath(path, tiles)).join(', ')}. Valid four-tile paths: ${fourTileWordPaths.map((path) => formatPath(path, tiles)).join(', ')}. Extra: ${extraQuartetPaths.map((path) => formatPath(path, tiles)).join(', ') || '(none)'}. Missing: ${missingQuartetPaths.map((path) => formatPath(path, tiles)).join(', ') || '(none)'}.`,
)
}

Expand All @@ -140,7 +119,7 @@ const formattedPuzzles = generatedPuzzles
)
.join('\n')

const content = `// Generated by scripts/generate-daily-words.mjs from src/data/daily-puzzles.json and the MIT-licensed wordlist-english package, backed by SCOWL.\n// Do not edit by hand; run npm run generate:daily-words.\n// Generation fails unless source tiles are unique, every tile has 2-4 letters, and each board has exactly the five target four-tile words with no extra valid quartets.\n\nexport const DAILY_PUZZLES = [\n${formattedPuzzles}\n] as const\n`
const content = `// Generated by scripts/generate-daily-words.mjs from src/data/daily-puzzles.json and the MIT-licensed wordlist-english package, backed by SCOWL.\n// Do not edit by hand; run npm run generate:daily-words.\n// Generation fails unless source tiles and configured quartet words are unique, every tile has 2-4 letters, and each board has exactly the five configured four-tile paths with no extra valid quartet paths.\n\nexport const DAILY_PUZZLES = [\n${formattedPuzzles}\n] as const\n`

if (process.argv.includes('--check')) {
const currentContent = existsSync(outputPath) ? readFileSync(outputPath, 'utf8') : ''
Expand Down
40 changes: 20 additions & 20 deletions src/data/daily-puzzles.json
Original file line number Diff line number Diff line change
Expand Up @@ -703,34 +703,34 @@
"date": "2026-05-16",
"quartets": [
[
"sig",
"nifi",
"can",
"tly"
"ann",
"ounc",
"em",
"ent"
],
[
"so",
"me",
"bo",
"dy"
"ex",
"ce",
"ssi",
"ve"
],
[
"ap",
"plic",
"at",
"ion"
"qu",
"al",
"if",
"ied"
],
[
"in",
"tel",
"lige",
"nce"
"av",
"ai",
"lab",
"le"
],
[
"inst",
"it",
"uti",
"on"
"di",
"sco",
"ura",
"ging"
]
]
}
Expand Down
6 changes: 3 additions & 3 deletions src/data/generated-daily-puzzles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by scripts/generate-daily-words.mjs from src/data/daily-puzzles.json and the MIT-licensed wordlist-english package, backed by SCOWL.
// Do not edit by hand; run npm run generate:daily-words.
// Generation fails unless source tiles are unique, every tile has 2-4 letters, and each board has exactly the five target four-tile words with no extra valid quartets.
// Generation fails unless source tiles and configured quartet words are unique, every tile has 2-4 letters, and each board has exactly the five configured four-tile paths with no extra valid quartet paths.

export const DAILY_PUZZLES = [
{
Expand Down Expand Up @@ -105,7 +105,7 @@ export const DAILY_PUZZLES = [
},
{
date: '2026-05-16',
quartets: [["sig","nifi","can","tly"],["so","me","bo","dy"],["ap","plic","at","ion"],["in","tel","lige","nce"],["inst","it","uti","on"]],
words: ['application', 'aptly', 'boat', 'body', 'bonce', 'boon', 'botel', 'can', 'canap', 'candy', 'canon', 'canso', 'inaptly', 'inion', 'institution', 'intelligence', 'ion', 'meat', 'onion', 'plication', 'significantly', 'soap', 'some', 'somebody', 'soon'],
quartets: [["ann","ounc","em","ent"],["ex","ce","ssi","ve"],["qu","al","if","ied"],["av","ai","lab","le"],["di","sco","ura","ging"]],
words: ['alle', 'alleging', 'annal', 'annex', 'announcement', 'available', 'cedi', 'dial', 'dice', 'disco', 'discouraging', 'dive', 'excessive', 'lab', 'leal', 'quai', 'qualified', 'veal'],
},
] as const
Loading