more words

This commit is contained in:
2026-10-24 14:29:08 -04:00
parent eae0dba308
commit f07b5fdadf
13 changed files with 3563 additions and 434 deletions

View File

@@ -6,7 +6,7 @@ export function createScoreRankBar({ onOpenWordsDrawer, onOpenReveal }) {
const rankInfo = state.getRank();
const quartilesSolved = state.getTargetQuartilesSolvedCount();
const totalWords = state.currentPuzzle ? (state.currentPuzzle.wordCount || state.currentPuzzle.words?.length || 0) : 0;
const foundWordsCount = state.foundWords.size;
const foundWordsCount = state.getListedFoundCount();
const isRevealed = !!state.isRevealed;
container.innerHTML = `

View File

@@ -14,12 +14,14 @@ export function createWordsDrawer({ onRevealAll }) {
const foundWordsList = Array.from(state.foundWords.values());
const allPuzzleWords = state.currentPuzzle.words || [];
const bonusFoundList = foundWordsList.filter(w => w.isBonus);
const listedFoundCount = foundWordsList.length - bonusFoundList.length;
const isRevealed = !!state.isRevealed;
// Active view: if user picked one use it, otherwise default to 'all' if revealed, 'found' if not
const activeView = viewMode !== null ? viewMode : (isRevealed ? 'all' : 'found');
const sourceWords = (activeView === 'all' && isRevealed) ? allPuzzleWords : foundWordsList;
const sourceWords = (activeView === 'all' && isRevealed) ? [...allPuzzleWords, ...bonusFoundList] : foundWordsList;
// Filter words according to current tab
let displayWords = [...sourceWords];
@@ -40,7 +42,7 @@ export function createWordsDrawer({ onRevealAll }) {
return a.word.localeCompare(b.word);
});
const countingSource = (activeView === 'all' && isRevealed) ? allPuzzleWords : foundWordsList;
const countingSource = sourceWords;
const quartilesCount = countingSource.filter(w => w.tileCount === 4).length;
const threeTilesCount = countingSource.filter(w => w.tileCount === 3).length;
const twoTilesCount = countingSource.filter(w => w.tileCount === 2).length;
@@ -65,8 +67,8 @@ export function createWordsDrawer({ onRevealAll }) {
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px;">
${
isRevealed
? `<strong>${foundWordsList.length}</strong> of ${allPuzzleWords.length} found (${state.score} pts) • ${allPuzzleWords.length - foundWordsList.length} revealed`
: `<strong>${foundWordsList.length}</strong> of ${allPuzzleWords.length} words found (${state.score} pts)`
? `<strong>${listedFoundCount}</strong> of ${allPuzzleWords.length} found (${state.score} pts) • ${allPuzzleWords.length - listedFoundCount} revealed${bonusFoundList.length ? ` • +${bonusFoundList.length} bonus` : ''}`
: `<strong>${listedFoundCount}</strong> of ${allPuzzleWords.length} words found${bonusFoundList.length ? ` +${bonusFoundList.length} bonus` : ''} (${state.score} pts)`
}
</div>
</div>
@@ -147,7 +149,9 @@ export function createWordsDrawer({ onRevealAll }) {
${isQuartile ? '<span title="Target Quartile (8 pts)"> ⭐</span>' : ''}
${
isFound
? '<span class="status-chip found">Found</span>'
? (w.isBonus
? '<span class="status-chip found" title="Valid word not on the main list">Bonus</span>'
: '<span class="status-chip found">Found</span>')
: '<span class="status-chip revealed">Revealed</span>'
}
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { DICTIONARY_SET, PREFIX_SET } from '../data/lexicon.js';
import { capWordList } 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 };
@@ -77,13 +78,9 @@ export function solveBoard(quartets, maxWords = 30) {
return a.word.localeCompare(b.word);
});
// Cap at maxWords (default: 30). Target quartiles and single-tile words
// (tiles that are words on their own) always keep their spot.
if (maxWords && words.length > maxWords) {
const reserved = words.filter(w => w.isTargetQuartile || w.tileCount === 1);
const rest = words.filter(w => !w.isTargetQuartile && w.tileCount !== 1);
words = [...reserved, ...rest.slice(0, Math.max(0, maxWords - reserved.length))];
}
// 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.
words = capWordList(words, maxWords);
// Final display sort: Quartiles first, then by points descending, then alphabetically
words.sort((a, b) => {

27
src/engine/word-cap.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* Caps a sorted candidate word list to `maxWords` entries.
*
* - Target quartiles and single-tile words always keep their spot.
* - 2-tile words are guaranteed roughly half of the remaining slots (when
* available) so long 3/4-tile combos can't crowd them out.
* - Remaining slots are filled in the incoming sort order.
*
* Words cut by the cap are still accepted during play as bonus words.
*/
export function capWordList(words, maxWords = 30) {
if (!maxWords || words.length <= maxWords) return words;
const reserved = words.filter(w => w.isTargetQuartile || w.tileCount === 1);
const others = words.filter(w => !w.isTargetQuartile && w.tileCount !== 1);
const slots = Math.max(0, maxWords - reserved.length);
const twoTile = others.filter(w => w.tileCount === 2);
const quota = Math.min(twoTile.length, Math.ceil(slots / 2));
const picked = new Set(twoTile.slice(0, quota));
for (const w of others) {
if (picked.size >= slots) break;
picked.add(w);
}
return [...reserved, ...others.filter(w => picked.has(w))];
}

View File

@@ -209,7 +209,7 @@ async function initApp() {
} else {
// Standard word
sound.playWordValid(result.points >= 4 ? 3 : result.points);
gameState.notifyToast(`+${result.points} pts: ${result.word.toUpperCase()}`, 'success', 2200);
gameState.notifyToast(`+${result.points} pts: ${result.word.toUpperCase()}${result.isBonus ? ' (bonus word)' : ''}`, 'success', 2200);
}
// Check rank progression

View File

@@ -1,5 +1,6 @@
import { CURATED_PUZZLES } from '../data/puzzles.js';
import { getRankInfo, QUARTILE_COMPLETION_BONUS } from '../engine/constants.js';
import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '../engine/constants.js';
import { DICTIONARY_SET } from '../data/lexicon.js';
class GameStateManager {
constructor() {
@@ -168,13 +169,21 @@ class GameStateManager {
const filteredEntries = [];
let recalculatedScore = 0;
for (const [w, entry] of parsed.foundWords) {
const matchedWord = validWordsMap.get(w.toLowerCase());
const key = w.toLowerCase();
const matchedWord = validWordsMap.get(key);
if (matchedWord) {
entry.points = matchedWord.points;
entry.tileCount = matchedWord.tileCount;
entry.tileSegments = matchedWord.tileSegments;
filteredEntries.push([w.toLowerCase(), entry]);
entry.isBonus = false;
filteredEntries.push([key, entry]);
recalculatedScore += matchedWord.points;
} else if (entry && DICTIONARY_SET.has(key)) {
// Valid word beyond the displayed 30-word list
entry.isBonus = true;
entry.points = POINTS_BY_TILE_COUNT[entry.tileCount] || 1;
filteredEntries.push([key, entry]);
recalculatedScore += entry.points;
}
}
this.foundWords = new Map(filteredEntries);
@@ -434,7 +443,13 @@ class GameStateManager {
}
// Look up in current puzzle's valid words list
const match = this.currentPuzzle.words.find(w => w.word.toLowerCase() === constructedWord);
const listed = this.currentPuzzle.words.find(w => w.word.toLowerCase() === constructedWord);
const tileCount = this.activeTileIds.length;
// Any real word built from the tiles counts, even if it was cut from the
// displayed 30-word list (accepted as a bonus word)
const isBonus = !listed && DICTIONARY_SET.has(constructedWord);
const match = listed || (isBonus ? { points: POINTS_BY_TILE_COUNT[tileCount] || 1 } : null);
if (!match) {
this.notifyToast(`"${constructedWord.toUpperCase()}" is not in the word list`, 'error');
@@ -446,7 +461,6 @@ class GameStateManager {
const tile = this.boardTiles.find(t => t.id === id);
return tile ? tile.text : '';
});
const tileCount = this.activeTileIds.length;
const isTargetQuartile = this.currentPuzzle.targetQuartiles.some(t => t.toLowerCase() === constructedWord);
const isQuartile = tileCount === 4;
@@ -457,6 +471,7 @@ class GameStateManager {
tileSegments,
isTargetQuartile,
isQuartile,
isBonus,
foundAt: Date.now()
};
@@ -496,6 +511,7 @@ class GameStateManager {
points: match.points,
isQuartile,
isTargetQuartile,
isBonus,
quartilesFound: quartilesCount,
bonusUnlocked
};
@@ -511,11 +527,20 @@ class GameStateManager {
return count;
}
// Number of found words that are on the puzzle's displayed list (excludes bonus words)
getListedFoundCount() {
let count = 0;
for (const entry of this.foundWords.values()) {
if (!entry.isBonus) count++;
}
return count;
}
// Get rank data
getRank() {
return getRankInfo(
this.score,
this.foundWords.size,
this.getListedFoundCount(),
this.currentPuzzle ? this.currentPuzzle.wordCount : 0
);
}