diff --git a/README.md b/README.md index cd493b0..1ad6975 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/scripts/add-daily-puzzle.mjs b/scripts/add-daily-puzzle.mjs index 01b9736..20abd81 100644 --- a/scripts/add-daily-puzzle.mjs +++ b/scripts/add-daily-puzzle.mjs @@ -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 @@ -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), @@ -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 } @@ -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) => { @@ -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' @@ -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) diff --git a/scripts/daily-puzzle-source.mjs b/scripts/daily-puzzle-source.mjs new file mode 100644 index 0000000..fc74903 --- /dev/null +++ b/scripts/daily-puzzle-source.mjs @@ -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 +} diff --git a/scripts/daily-puzzle-source.test.mjs b/scripts/daily-puzzle-source.test.mjs new file mode 100644 index 0000000..8652646 --- /dev/null +++ b/scripts/daily-puzzle-source.test.mjs @@ -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\)/, + ) + }) +}) diff --git a/scripts/generate-daily-words.mjs b/scripts/generate-daily-words.mjs index ad36747..4aaa5e0 100644 --- a/scripts/generate-daily-words.mjs +++ b/scripts/generate-daily-words.mjs @@ -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) => { @@ -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 } @@ -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) => { @@ -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)'}.`, ) } @@ -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') : '' diff --git a/src/data/daily-puzzles.json b/src/data/daily-puzzles.json index 4d387dc..0e6070d 100644 --- a/src/data/daily-puzzles.json +++ b/src/data/daily-puzzles.json @@ -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" ] ] } diff --git a/src/data/generated-daily-puzzles.ts b/src/data/generated-daily-puzzles.ts index dcefb18..9e17168 100644 --- a/src/data/generated-daily-puzzles.ts +++ b/src/data/generated-daily-puzzles.ts @@ -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 = [ { @@ -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 diff --git a/src/lib/puzzle.test.ts b/src/lib/puzzle.test.ts index 278bbc3..9fa7c95 100644 --- a/src/lib/puzzle.test.ts +++ b/src/lib/puzzle.test.ts @@ -34,6 +34,18 @@ describe('word validation', () => { }) }) + it('rejects an alternate tile path that spells a known word but is not the stored puzzle path', () => { + const exactPathPuzzle: TilePuzzle = { + id: 'exact-path', + title: 'Exact path board', + tiles: ['ab', 'c', 'a', 'bc'], + words: [{ word: 'abc', tileIds: [0, 1] }], + } + + expect(validateGuess(exactPathPuzzle, [0, 1])).toMatchObject({ ok: true, word: 'abc' }) + expect(validateGuess(exactPathPuzzle, [2, 3])).toEqual({ ok: false, reason: 'Not in this puzzle.' }) + }) + it('rejects unknown tile combinations and duplicate tile use with clear reasons', () => { expect(validateGuess(miniPuzzle, [3, 8])).toEqual({ ok: false, reason: 'Not in this puzzle.' }) expect(validateGuess(miniPuzzle, [3, 3])).toEqual({ ok: false, reason: 'Each tile can only be used once.' }) @@ -261,7 +273,7 @@ describe('daily puzzle dictionary coverage', () => { .map((quartet) => quartet.join('')) .sort((left, right) => left.localeCompare(right)) - expect(validateExactQuartetPuzzle(puzzle, targetQuartetWords), `${date} failed exact quartet validation`).toEqual({ + expect(validateExactQuartetPuzzle(puzzle, targetQuartetWords), `${date} failed exact quartet validation`).toMatchObject({ ok: true, quartetWords: targetQuartetWords, }) @@ -282,14 +294,65 @@ describe('daily puzzle dictionary coverage', () => { dictionary: ['abcd', 'abce', 'efgh', 'ijkl', 'mnop', 'qrst'], }) - expect(validateExactQuartetPuzzle(puzzle, ['abcd', 'efgh', 'ijkl', 'mnop', 'qrst'])).toEqual({ + expect(validateExactQuartetPuzzle(puzzle, ['abcd', 'efgh', 'ijkl', 'mnop', 'qrst'])).toMatchObject({ ok: false, quartetWords: ['abcd', 'abce', 'efgh', 'ijkl', 'mnop', 'qrst'], targetQuartetWords: ['abcd', 'efgh', 'ijkl', 'mnop', 'qrst'], extraQuartetWords: ['abce'], missingTargetQuartetWords: [], - reason: 'Expected exactly 5 target quartets and no extra four-tile words.', + reason: 'Expected exactly 5 target quartet paths and no extra four-tile paths.', + }) + }) + + it('reports duplicate four-tile paths for the same quartet word as extra valid paths', () => { + const puzzle = buildPuzzleFromQuartets({ + seed: 'duplicate-four-tile-path', + title: 'Duplicate quartet path board', + quartets: [ + ['ab', 'cd', 'ef', 'gh'], + ['abc', 'de', 'f', 'zz'], + ['ij', 'kl', 'mn', 'op'], + ['qr', 'st', 'uv', 'wx'], + ['ya', 'yb', 'yc', 'yd'], + ], + dictionary: ['abcdefgh', 'abcdefzz', 'ijklmnop', 'qrstuvwx', 'yaybycyd'], + }) + + const result = validateExactQuartetPuzzle(puzzle, ['abcdefgh', 'abcdefzz', 'ijklmnop', 'qrstuvwx', 'yaybycyd']) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.extraQuartetWords).toEqual(['abcdefgh', 'abcdefzz']) + expect(result.missingTargetQuartetWords).toEqual([]) + expect(result.reason).toBe('Expected exactly 5 target quartet paths and no extra four-tile paths.') + expect(result.quartetPaths.map((path) => path.word)).toEqual([ + 'abcdefgh', + 'abcdefgh', + 'abcdefzz', + 'abcdefzz', + 'ijklmnop', + 'qrstuvwx', + 'yaybycyd', + ]) + expect(new Set(result.quartetPaths.map((path) => path.signature)).size).toBe(7) + } + }) + + it('keeps the patched May 16 board to exactly five configured quartet tile paths', () => { + const date = '2026-05-16' + const puzzle = createDailyPuzzle(date) + const targetQuartetWords = resolveDailyPuzzleData(date).quartets + .map((quartet) => quartet.join('')) + .sort((left, right) => left.localeCompare(right)) + + const result = validateExactQuartetPuzzle(puzzle, targetQuartetWords) + + expect(result, `${date} failed path-aware quartet validation`).toMatchObject({ + ok: true, + quartetWords: targetQuartetWords, }) + expect(result.quartetPaths).toHaveLength(5) + expect(result.quartetPaths.map((path) => path.word)).toEqual(targetQuartetWords) }) }) diff --git a/src/lib/puzzle.ts b/src/lib/puzzle.ts index a2dd052..d9e54b3 100644 --- a/src/lib/puzzle.ts +++ b/src/lib/puzzle.ts @@ -38,6 +38,9 @@ export const QUARTILE_COMPLETION_COUNT = 5 export const QUARTILE_COMPLETION_BONUS = 40 const normalize = (value: string) => value.toLowerCase().replace(/[^a-z]/g, '') +const pathSignature = (tileIds: number[]) => tileIds.join(',') +const sameTilePath = (left: number[], right: number[]) => + left.length === right.length && left.every((tileId, index) => tileId === right[index]) const hashSeed = (seed: string) => { let hash = 2166136261 @@ -146,7 +149,9 @@ export const validateGuess = (puzzle: TilePuzzle, tileIds: number[]): GuessResul } const guess = normalize(tileIds.map((tileId) => puzzle.tiles[tileId]).join('')) - const word = puzzle.words.find((candidate) => candidate.word === guess) + const word = puzzle.words.find( + (candidate) => candidate.word === guess && sameTilePath(candidate.tileIds, tileIds), + ) if (!word) { return { ok: false, reason: 'Not in this puzzle.' } @@ -155,8 +160,8 @@ export const validateGuess = (puzzle: TilePuzzle, tileIds: number[]): GuessResul return { ok: true, word: word.word, - tileIds: [...tileIds], - points: scoreWord(tileIds), + tileIds: [...word.tileIds], + points: scoreWord(word.tileIds), isQuartet: Boolean(word.isQuartet), } } @@ -233,55 +238,110 @@ export const getMedalAward = (puzzle: TilePuzzle, foundWords: string[]): MedalTi return 'none' } +export type QuartetPath = { + word: string + tileIds: number[] + signature: string +} + export type ExactQuartetPuzzleValidation = | { ok: true quartetWords: string[] + quartetPaths: QuartetPath[] } | { ok: false quartetWords: string[] + quartetPaths: QuartetPath[] targetQuartetWords: string[] + targetQuartetPaths: QuartetPath[] extraQuartetWords: string[] + extraQuartetPaths: QuartetPath[] missingTargetQuartetWords: string[] + missingTargetQuartetPaths: QuartetPath[] reason: string } -export const getFourTileWords = (puzzle: TilePuzzle) => - puzzle.words - .filter((word) => word.tileIds.length === MAX_TILES_PER_WORD) - .map((word) => word.word) - .sort((left, right) => left.localeCompare(right)) +const sortQuartetPaths = (left: QuartetPath, right: QuartetPath) => + left.word.localeCompare(right.word) || left.signature.localeCompare(right.signature) + +export const getFourTileWordPaths = (puzzle: TilePuzzle): QuartetPath[] => { + const puzzleWordSet = new Set(puzzle.words.map((word) => word.word)) + const paths: QuartetPath[] = [] + + const search = (prefix: string, usedTileIds: Set, tileIds: number[]) => { + if (tileIds.length === MAX_TILES_PER_WORD) { + if (puzzleWordSet.has(prefix)) { + paths.push({ word: prefix, tileIds: [...tileIds], signature: pathSignature(tileIds) }) + } + return + } + + for (let tileId = 0; tileId < puzzle.tiles.length; tileId += 1) { + if (usedTileIds.has(tileId)) { + continue + } + + usedTileIds.add(tileId) + search(prefix + normalize(puzzle.tiles[tileId]), usedTileIds, [...tileIds, tileId]) + usedTileIds.delete(tileId) + } + } + + search('', new Set(), []) + return paths.sort(sortQuartetPaths) +} + +export const getFourTileWords = (puzzle: TilePuzzle) => getFourTileWordPaths(puzzle).map((path) => path.word) export const validateExactQuartetPuzzle = ( puzzle: TilePuzzle, targetQuartetWords: string[], ): ExactQuartetPuzzleValidation => { - const quartetWords = getFourTileWords(puzzle) + const quartetPaths = getFourTileWordPaths(puzzle) + const quartetWords = quartetPaths.map((path) => path.word) const targetWords = [...new Set(targetQuartetWords.map(normalize).filter(Boolean))].sort((left, right) => left.localeCompare(right), ) - const quartetWordSet = new Set(quartetWords) const targetWordSet = new Set(targetWords) - const extraQuartetWords = quartetWords.filter((word) => !targetWordSet.has(word)) - const missingTargetQuartetWords = targetWords.filter((word) => !quartetWordSet.has(word)) + const targetQuartetPaths = puzzle.words + .filter((word) => word.isQuartet && word.tileIds.length === MAX_TILES_PER_WORD && targetWordSet.has(word.word)) + .map((word) => ({ word: word.word, tileIds: [...word.tileIds], signature: pathSignature(word.tileIds) })) + .sort(sortQuartetPaths) + const quartetPathSignatures = new Set(quartetPaths.map((path) => path.signature)) + const targetPathSignatures = new Set(targetQuartetPaths.map((path) => path.signature)) + const targetPathWords = new Set(targetQuartetPaths.map((path) => path.word)) + const extraQuartetPaths = quartetPaths.filter((path) => !targetPathSignatures.has(path.signature)) + const missingTargetQuartetPaths = targetQuartetPaths.filter((path) => !quartetPathSignatures.has(path.signature)) + const missingTargetQuartetWords = [ + ...targetWords.filter((word) => !targetPathWords.has(word)), + ...missingTargetQuartetPaths.map((path) => path.word), + ] + const duplicateTargetPathCount = targetQuartetPaths.length - targetPathSignatures.size if ( - quartetWords.length === QUARTILE_COMPLETION_COUNT && + quartetPaths.length === QUARTILE_COMPLETION_COUNT && targetWords.length === QUARTILE_COMPLETION_COUNT && - extraQuartetWords.length === 0 && + targetQuartetPaths.length === QUARTILE_COMPLETION_COUNT && + duplicateTargetPathCount === 0 && + extraQuartetPaths.length === 0 && missingTargetQuartetWords.length === 0 ) { - return { ok: true, quartetWords } + return { ok: true, quartetWords, quartetPaths } } return { ok: false, quartetWords, + quartetPaths, targetQuartetWords: targetWords, - extraQuartetWords, + targetQuartetPaths, + extraQuartetWords: extraQuartetPaths.map((path) => path.word), + extraQuartetPaths, missingTargetQuartetWords, - reason: 'Expected exactly 5 target quartets and no extra four-tile words.', + missingTargetQuartetPaths, + reason: 'Expected exactly 5 target quartet paths and no extra four-tile paths.', } }