Reveal failed puzzles and improve clue coverage
Build and push image / build (push) Successful in 1m19s

This commit is contained in:
alexandrev-tibco
2026-05-07 11:53:14 +02:00
parent 53214f96a5
commit f784c9c722
6 changed files with 7304 additions and 3069 deletions
+27 -1
View File
@@ -56,6 +56,7 @@ export default function Home({ sponsor }: HomeProps) {
const [started, setStarted] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [statsOpen, setStatsOpen] = useState(false);
const [revealedSolutions, setRevealedSolutions] = useState<Partial<Record<Difficulty, boolean>>>({});
const [selectedPosition, setSelectedPosition] = useState<Position>("DEF");
const [stats, setStats] = useState<GlobalStats>(() => createInitialStats());
const [draft, setDraft] = useState<Record<Position, string | null>>(() => getFixedGuess(dailyPuzzles[0]));
@@ -65,6 +66,9 @@ export default function Home({ sponsor }: HomeProps) {
const activeProgress = progress[activeDifficulty];
const isLocked = activeProgress.locked;
const isFinished = activeProgress.solved || activeProgress.attempts.length >= MAX_ATTEMPTS;
const hasFailed = !activeProgress.solved && activeProgress.attempts.length >= MAX_ATTEMPTS;
const solutionRevealed = revealedSolutions[activeDifficulty] === true;
const pitchDraft = solutionRevealed ? activePuzzle.solution : draft;
const t = useMemo(() => getDictionary(locale), [locale]);
const quote = useMemo(() => quoteForDay(dateKey, locale), [dateKey, locale]);
const globalStatsShareText = useMemo(() => buildGlobalStatsShareText(stats, t), [stats, t]);
@@ -211,6 +215,18 @@ export default function Home({ sponsor }: HomeProps) {
setMessage(t.hintRevealed(positionToReveal));
}
function revealSolution() {
if (!hasFailed) return;
setRevealedSolutions((current) => ({ ...current, [activeDifficulty]: true }));
setDraft(activePuzzle.solution);
setMessage(t.solutionRevealed);
trackEvent("solution_revealed", {
difficulty: activeDifficulty,
puzzle_number: getPuzzleNumber(dateKey),
});
}
function changeLocale(nextLocale: Locale) {
setLocale(nextLocale);
window.localStorage.setItem("hidden11-locale", nextLocale);
@@ -239,6 +255,15 @@ export default function Home({ sponsor }: HomeProps) {
const actionButtons = (
<div className="flex items-center gap-1.5">
{hasFailed && !solutionRevealed ? (
<button
type="button"
onClick={revealSolution}
className="h-8 rounded-full border border-emerald-300/40 bg-emerald-300/15 px-3 text-[10px] font-black uppercase text-emerald-100 transition hover:bg-emerald-300/25"
>
{t.showSolution}
</button>
) : null}
<button
type="button"
onClick={revealHint}
@@ -398,11 +423,12 @@ export default function Home({ sponsor }: HomeProps) {
<Pitch
actions={actionButtons}
collapsed={fieldCollapsed}
draft={draft}
draft={pitchDraft}
fixedPosition={activePuzzle.fixedPosition}
lastGuess={activeProgress.attempts[activeProgress.attempts.length - 1]}
onToggleCollapsed={() => setFieldCollapsed((current) => !current)}
selectedPosition={selectedPosition}
solutionRevealed={solutionRevealed}
t={t}
onSelectPosition={setSelectedPosition}
/>
+3
View File
@@ -12,6 +12,7 @@ type PitchProps = {
lastGuess?: Guess;
onToggleCollapsed: () => void;
selectedPosition: Position;
solutionRevealed?: boolean;
t: Dictionary;
onSelectPosition: (position: Position) => void;
};
@@ -32,10 +33,12 @@ export default function Pitch({
lastGuess,
onToggleCollapsed,
selectedPosition,
solutionRevealed = false,
t,
onSelectPosition,
}: PitchProps) {
function feedbackFor(position: Position): Feedback {
if (solutionRevealed) return "correct";
if (position === fixedPosition) return "correct";
return lastGuess?.slots.find((slot) => slot.position === position)?.feedback ?? "empty";
}
+7138 -3022
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -58,6 +58,7 @@ export type Dictionary = {
puzzleFinished: string;
searchPlaceholder: (position: string) => string;
select: string;
showSolution: string;
share: string;
challengeFriend: string;
challengeShareLead: string;
@@ -79,6 +80,7 @@ export type Dictionary = {
bestTime: string;
distribution: string;
solvePrevious: string;
solutionRevealed: string;
solved: string;
time: string;
tries: string;
@@ -145,6 +147,7 @@ const dictionaries: Record<Locale, Dictionary> = {
puzzleFinished: "Puzzle finished.",
searchPlaceholder: (position) => `Search player for ${position}`,
select: "Select",
showSolution: "Show solution",
share: "Share",
challengeFriend: "Challenge a friend",
challengeShareLead: "I played today's hidden11. Can you beat me?",
@@ -166,6 +169,7 @@ const dictionaries: Record<Locale, Dictionary> = {
bestTime: "Best time",
distribution: "Distribution",
solvePrevious: "Solve the previous puzzle to unlock this lineup.",
solutionRevealed: "Solution revealed.",
solved: "solved",
time: "Time",
tries: "tries",
@@ -230,6 +234,7 @@ const dictionaries: Record<Locale, Dictionary> = {
puzzleFinished: "Puzzle terminado.",
searchPlaceholder: (position) => `Buscar jugador para ${position}`,
select: "Elegir",
showSolution: "Ver solución",
share: "Compartir",
challengeFriend: "Retar a un amigo",
challengeShareLead: "He jugado al hidden11 de hoy. ¿Puedes superarme?",
@@ -251,6 +256,7 @@ const dictionaries: Record<Locale, Dictionary> = {
bestTime: "Mejor tiempo",
distribution: "Distribución",
solvePrevious: "Resuelve el puzzle anterior para desbloquear esta alineación.",
solutionRevealed: "Solución revelada.",
solved: "resuelto",
time: "Tiempo",
tries: "intentos",
@@ -329,6 +335,7 @@ const dictionaries: Record<Locale, Dictionary> = {
puzzleFinished: "Puzzle terminé.",
searchPlaceholder: (position) => `Chercher un joueur pour ${position}`,
select: "Choisir",
showSolution: "Voir la solution",
share: "Partager",
challengeFriend: "Défier un ami",
challengeShareLead: "J'ai joué au hidden11 du jour. Peux-tu faire mieux ?",
@@ -350,6 +357,7 @@ const dictionaries: Record<Locale, Dictionary> = {
bestTime: "Meilleur temps",
distribution: "Distribution",
solvePrevious: "Résous le puzzle précédent pour déverrouiller cette équipe.",
solutionRevealed: "Solution révélée.",
solved: "résolu",
time: "Temps",
tries: "essais",
@@ -414,6 +422,7 @@ const dictionaries: Record<Locale, Dictionary> = {
puzzleFinished: "Rätsel beendet.",
searchPlaceholder: (position) => `Spieler für ${position} suchen`,
select: "Wählen",
showSolution: "Lösung anzeigen",
share: "Teilen",
challengeFriend: "Freund herausfordern",
challengeShareLead: "Ich habe das heutige hidden11 gespielt. Schaffst du mehr?",
@@ -435,6 +444,7 @@ const dictionaries: Record<Locale, Dictionary> = {
bestTime: "Beste Zeit",
distribution: "Verteilung",
solvePrevious: "Löse das vorherige Rätsel, um diese Aufstellung freizuschalten.",
solutionRevealed: "Lösung angezeigt.",
solved: "gelöst",
time: "Zeit",
tries: "Versuche",
@@ -499,6 +509,7 @@ const dictionaries: Record<Locale, Dictionary> = {
puzzleFinished: "Puzzle finito.",
searchPlaceholder: (position) => `Cerca giocatore per ${position}`,
select: "Scegli",
showSolution: "Mostra soluzione",
share: "Condividi",
challengeFriend: "Sfida un amico",
challengeShareLead: "Ho giocato all'hidden11 di oggi. Riesci a battermi?",
@@ -520,6 +531,7 @@ const dictionaries: Record<Locale, Dictionary> = {
bestTime: "Miglior tempo",
distribution: "Distribuzione",
solvePrevious: "Risolvi il puzzle precedente per sbloccare questa formazione.",
solutionRevealed: "Soluzione rivelata.",
solved: "risolto",
time: "Tempo",
tries: "tentativi",
@@ -584,6 +596,7 @@ const dictionaries: Record<Locale, Dictionary> = {
puzzleFinished: "Puzzle terminado.",
searchPlaceholder: (position) => `Pesquisar jogador para ${position}`,
select: "Escolher",
showSolution: "Ver solução",
share: "Partilhar",
challengeFriend: "Desafiar um amigo",
challengeShareLead: "Joguei o hidden11 de hoje. Consegues superar-me?",
@@ -605,6 +618,7 @@ const dictionaries: Record<Locale, Dictionary> = {
bestTime: "Melhor tempo",
distribution: "Distribuição",
solvePrevious: "Resolve o puzzle anterior para desbloquear este onze.",
solutionRevealed: "Solução revelada.",
solved: "resolvido",
time: "Tempo",
tries: "tentativas",
@@ -671,6 +685,11 @@ function translateGeneratedClue(clue: string, locale: Locale): string {
return tLeagueCount(locale, Number(leagueCount[1]), leagueCount[2]);
}
const positionLeague = clue.match(/^The (.+) plays in (.+)\.$/);
if (positionLeague) {
return tPositionLeague(locale, positionLeague[1], positionLeague[2]);
}
const differentClubs = clue.match(/^All five players come from different clubs\.$/);
if (differentClubs) {
return tAllDifferentClubs(locale);
@@ -691,6 +710,11 @@ function translateGeneratedClue(clue: string, locale: Locale): string {
return tWonLeagueTitles(locale, wonLeagueTitles[1], Number(wonLeagueTitles[2]));
}
const wonChampionsTitles = clue.match(/^The (.+) has won (\d+) Champions League titles?\.$/);
if (wonChampionsTitles) {
return tWonChampionsTitles(locale, wonChampionsTitles[1], Number(wonChampionsTitles[2]));
}
const wonCountries = clue.match(/^The (.+) has won league titles in (.+)\.$/);
if (wonCountries) {
return tWonCountries(locale, wonCountries[1], wonCountries[2]);
@@ -878,6 +902,14 @@ function tLeagueCount(locale: Locale, count: number, league: string): string {
return `${count} ${count === 1 ? "jogador joga" : "jogadores jogam"} na ${league}.`;
}
function tPositionLeague(locale: Locale, position: string, league: string): string {
if (locale === "es") return `El ${pos(locale, position)} juega en ${league}.`;
if (locale === "fr") return `Le ${pos(locale, position)} évolue en ${league}.`;
if (locale === "de") return `Der ${pos(locale, position)} spielt in der ${league}.`;
if (locale === "it") return `Il ${pos(locale, position)} gioca in ${league}.`;
return `O ${pos(locale, position)} joga na ${league}.`;
}
function tAllDifferentClubs(locale: Locale): string {
if (locale === "es") return "Los cinco jugadores son de clubes diferentes.";
if (locale === "fr") return "Les cinq joueurs viennent de clubs différents.";
@@ -910,6 +942,14 @@ function tWonLeagueTitles(locale: Locale, position: string, count: number): stri
return `O ${pos(locale, position)} ganhou ${count} ${count === 1 ? "campeonato" : "campeonatos"}.`;
}
function tWonChampionsTitles(locale: Locale, position: string, count: number): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado ${count} ${count === 1 ? "Champions League" : "Champions League"}.`;
if (locale === "fr") return `Le ${pos(locale, position)} a gagné ${count} ${count === 1 ? "Ligue des champions" : "Ligues des champions"}.`;
if (locale === "de") return `Der ${pos(locale, position)} hat ${count} Champions-League-Titel gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto ${count} ${count === 1 ? "Champions League" : "Champions League"}.`;
return `O ${pos(locale, position)} ganhou ${count} ${count === 1 ? "Champions League" : "Champions League"}.`;
}
function tWonCountries(locale: Locale, position: string, value: string): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado ligas en ${countries(locale, value)}.`;
if (locale === "fr") return `Le ${pos(locale, position)} a gagné des championnats en ${countries(locale, value)}.`;
+66 -46
View File
@@ -3,6 +3,13 @@ import players from "../data/players.json" with { type: "json" };
const POSITIONS = ["GK", "DEF", "MID", "WING", "ST"];
const DIFFICULTIES = ["easy", "medium", "hard"];
const positionLabels = {
GK: "goalkeeper",
DEF: "defender",
MID: "midfielder",
WING: "winger",
ST: "striker",
};
const START_DATE_KEY = process.env.PUZZLE_START_DATE ?? "2026-05-05";
const DAYS = 365;
const byPosition = POSITIONS.map((position) => players.filter((player) => player.position === position));
@@ -18,13 +25,14 @@ for (let day = 0; day < DAYS; day += 1) {
const index = permuteIndex(ordinal, totalCombinations);
const lineup = lineupFromIndex(index);
const difficulty = DIFFICULTIES[difficultyIndex];
const fixedPosition = POSITIONS[(ordinal + 2) % POSITIONS.length];
puzzles.push({
id: `${dateKey}-${difficulty}`,
date: dateKey,
difficulty,
solution: Object.fromEntries(POSITIONS.map((position) => [position, lineup[position].id])),
fixedPosition: POSITIONS[(ordinal + 2) % POSITIONS.length],
clues: buildClues(lineup, difficulty),
fixedPosition,
clues: buildClues(lineup, difficulty, fixedPosition),
});
}
}
@@ -63,46 +71,40 @@ function lineupFromIndex(index) {
};
}
function buildClues(lineup, difficulty) {
function buildClues(lineup, difficulty, fixedPosition) {
const hiddenPositions = POSITIONS.filter((position) => position !== fixedPosition);
if (difficulty === "easy") {
return [
buildClubClue(lineup),
buildLeagueCountClue(lineup),
`The striker is from ${lineup.ST.nationality}.`,
`The winger is from ${lineup.WING.nationality}.`,
];
return hiddenPositions.flatMap((position) => [
buildNationalityClue(position, lineup[position]),
buildCurrentLeagueClue(position, lineup[position]),
]);
}
if (difficulty === "medium") {
return [
buildTitleCountryClue(lineup.GK, "The goalkeeper"),
buildTitleCountryClue(lineup.DEF, "The defender"),
buildFormerClubClue(lineup.WING, lineup.ST, "The winger and striker"),
buildNumericLeagueClue(lineup.MID, "The midfielder"),
];
return dedupeClues([
...hiddenPositions.map((position) => buildTitleCountryClue(lineup[position], subjectFor(position))),
...buildHiddenPairClues(lineup, hiddenPositions).slice(0, 2),
]);
}
return [
buildTitleCountryPairClue(lineup.DEF, "The defender"),
buildTitleCountryPairClue(lineup.MID, "The midfielder"),
buildFormerClubClue(lineup.GK, lineup.MID, "The goalkeeper and midfielder"),
buildLeagueTitleComparisonClue(lineup.GK, lineup.WING),
buildChampionsLeagueComparisonClue(lineup.ST, lineup.DEF),
];
return dedupeClues([
...hiddenPositions.map((position) => buildTitleCountryPairClue(lineup[position], subjectFor(position))),
...hiddenPositions.map((position) => buildChampionsLeagueCountClue(lineup[position], subjectFor(position))),
...buildHiddenPairClues(lineup, hiddenPositions).slice(0, 3),
]);
}
function buildClubClue(lineup) {
const clubCounts = countBy(Object.values(lineup), (player) => player.club);
const shared = Object.entries(clubCounts).find(([, count]) => count > 1);
if (!shared) return "All five players come from different clubs.";
if (shared[1] === 2) return "Two players share the same club.";
return `${shared[1]} players share the same club.`;
function subjectFor(position) {
return `The ${positionLabels[position]}`;
}
function buildLeagueCountClue(lineup) {
const leagueCounts = countBy(Object.values(lineup), (player) => player.league);
const [league, count] = Object.entries(leagueCounts).sort((a, b) => b[1] - a[1])[0];
return `${count} player${count === 1 ? "" : "s"} play in ${league}.`;
function buildNationalityClue(position, player) {
return `${subjectFor(position)} is from ${player.nationality}.`;
}
function buildCurrentLeagueClue(position, player) {
return `${subjectFor(position)} plays in ${player.league}.`;
}
function buildTitleCountryClue(player, subject) {
@@ -113,6 +115,10 @@ function buildTitleCountryClue(player, subject) {
return `${subject} has won ${player.leagueTitles} league titles.`;
}
function buildChampionsLeagueCountClue(player, subject) {
return `${subject} has won ${player.championsLeagueTitles} Champions League title${player.championsLeagueTitles === 1 ? "" : "s"}.`;
}
function buildNumericLeagueClue(player, subject) {
return buildLeagueTitleCountClue(player, subject);
}
@@ -135,6 +141,20 @@ function buildFormerClubClue(playerA, playerB, subject) {
return buildLeagueTitleCountClue(playerA, subject.split(" and ")[0]);
}
function buildHiddenPairClues(lineup, hiddenPositions) {
const clues = [];
for (let leftIndex = 0; leftIndex < hiddenPositions.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < hiddenPositions.length; rightIndex += 1) {
const left = hiddenPositions[leftIndex];
const right = hiddenPositions[rightIndex];
clues.push(buildFormerClubClue(lineup[left], lineup[right], `${subjectFor(left)} and ${positionLabels[right]}`));
clues.push(buildLeagueTitleComparisonClue(left, lineup[left], right, lineup[right]));
clues.push(buildChampionsLeagueComparisonClue(left, lineup[left], right, lineup[right]));
}
}
return dedupeClues(clues);
}
function buildTitleCountryPairClue(player, subject) {
const countries = player.leagueTitleCountries ?? [];
if (countries.length >= 2) {
@@ -150,32 +170,32 @@ function buildLeagueTitleCountClue(player, subject) {
return `${subject} has won ${player.leagueTitles} league title${player.leagueTitles === 1 ? "" : "s"}.`;
}
function buildLeagueTitleComparisonClue(playerA, playerB) {
function buildLeagueTitleComparisonClue(positionA, playerA, positionB, playerB) {
const subjectA = subjectFor(positionA);
const subjectB = `the ${positionLabels[positionB]}`;
if (playerA.leagueTitles > playerB.leagueTitles) {
return `The goalkeeper has more league titles than the winger.`;
return `${subjectA} has more league titles than ${subjectB}.`;
}
if (playerB.leagueTitles > playerA.leagueTitles) {
return `The winger has more league titles than the goalkeeper.`;
return `${subjectFor(positionB)} has more league titles than the ${positionLabels[positionA]}.`;
}
return `The goalkeeper and winger have won the same number of league titles.`;
return `${subjectA} and ${positionLabels[positionB]} have won the same number of league titles.`;
}
function buildChampionsLeagueComparisonClue(playerA, playerB) {
function buildChampionsLeagueComparisonClue(positionA, playerA, positionB, playerB) {
const subjectA = subjectFor(positionA);
const subjectB = `the ${positionLabels[positionB]}`;
if (playerA.championsLeagueTitles > playerB.championsLeagueTitles) {
return `The striker has more Champions League titles than the defender.`;
return `${subjectA} has more Champions League titles than ${subjectB}.`;
}
if (playerB.championsLeagueTitles > playerA.championsLeagueTitles) {
return `The defender has more Champions League titles than the striker.`;
return `${subjectFor(positionB)} has more Champions League titles than the ${positionLabels[positionA]}.`;
}
return `The striker and defender have won the same number of Champions League titles.`;
return `${subjectA} and ${positionLabels[positionB]} have won the same number of Champions League titles.`;
}
function countBy(items, getKey) {
return items.reduce((acc, item) => {
const key = getKey(item);
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});
function dedupeClues(clues) {
return [...new Set(clues)];
}
function addDays(dateKey, daysToAdd) {
+30
View File
@@ -3,6 +3,13 @@ import puzzles from "../data/puzzles.json" with { type: "json" };
const byId = new Map(players.map((player) => [player.id, player]));
const positions = ["GK", "DEF", "MID", "WING", "ST"];
const positionLabels = {
GK: "goalkeeper",
DEF: "defender",
MID: "midfielder",
WING: "winger",
ST: "striker",
};
const dates = [...new Set(puzzles.map((puzzle) => puzzle.date))].sort();
function solutionPlayers(puzzle) {
@@ -52,6 +59,29 @@ for (const puzzle of puzzles) {
}
}
const fixedLabel = positionLabels[puzzle.fixedPosition];
if (puzzle.clues.some((clue) => clue.toLowerCase().includes(fixedLabel))) {
throw new Error(`${puzzle.id}: clue references fixed position ${puzzle.fixedPosition}`);
}
const mentionedHiddenPositions = new Set();
for (const clue of puzzle.clues) {
const normalized = clue.toLowerCase();
for (const position of positions) {
if (position === puzzle.fixedPosition) continue;
if (normalized.includes(positionLabels[position])) {
mentionedHiddenPositions.add(position);
}
}
}
for (const position of positions) {
if (position === puzzle.fixedPosition) continue;
if (!mentionedHiddenPositions.has(position)) {
throw new Error(`${puzzle.id}: no clue references hidden position ${position}`);
}
}
const lineup = solutionPlayers(puzzle);
const signature = JSON.stringify(puzzle.solution);
if (solutionSignatures.has(signature)) {