Fix Close label translation and replace liga with campeonato in clues
Build and push image / build (push) Successful in 1m19s

- Fix feedback color label: Close → Cerca/Proche/Nah/Vicino/Perto
  in all non-English locales (was using the verb "to close" instead
  of "near/close")
- Replace "liga/ligas" with "campeonato/campeonatos" in all ES
  translations that refer to league titles, to avoid confusion with
  the competition name LaLiga
- Add new player fields (ballonDOr, goldenBoot, worldCupWinner,
  copaAmericaWinner, eurocupWinner) and rework clue generation with
  difficulty-aware mixing (easy=all direct, medium=2 direct+2 harder,
  hard=1 direct+2 medium+comparisons)
- Add new clue types: club, trophy (World Cup/Euro/Copa), award
  (Ballon d'Or/Golden Boot), with full i18n support in 6 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-29 07:18:58 +02:00
parent 5effa81a82
commit 2a8d055da3
5 changed files with 4424 additions and 4070 deletions
+235 -40
View File
@@ -7,9 +7,14 @@
"club": "Liverpool",
"league": "Premier League",
"formerClubs": ["Internacional", "Roma"],
"championsLeagueTitles": 0,
"championsLeagueTitles": 1,
"leagueTitles": 2,
"leagueTitleCountries": ["Brazil", "England"]
"leagueTitleCountries": ["Brazil", "England"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "courtois",
@@ -21,7 +26,12 @@
"formerClubs": ["Genk", "Atletico Madrid", "Chelsea"],
"championsLeagueTitles": 2,
"leagueTitles": 4,
"leagueTitleCountries": ["Spain", "England"]
"leagueTitleCountries": ["Spain", "England"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "donnarumma",
@@ -33,7 +43,12 @@
"formerClubs": ["AC Milan", "Paris Saint-Germain"],
"championsLeagueTitles": 0,
"leagueTitles": 2,
"leagueTitleCountries": ["France"]
"leagueTitleCountries": ["France"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": true
},
{
"id": "ederson",
@@ -45,7 +60,12 @@
"formerClubs": ["Rio Ave", "Benfica"],
"championsLeagueTitles": 1,
"leagueTitles": 7,
"leagueTitleCountries": ["Portugal", "England"]
"leagueTitleCountries": ["Portugal", "England"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "mamardashvili",
@@ -57,7 +77,12 @@
"formerClubs": ["Dinamo Tbilisi", "Valencia"],
"championsLeagueTitles": 0,
"leagueTitles": 0,
"leagueTitleCountries": []
"leagueTitleCountries": [],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "trubin",
@@ -69,7 +94,12 @@
"formerClubs": ["Shakhtar Donetsk"],
"championsLeagueTitles": 0,
"leagueTitles": 2,
"leagueTitleCountries": ["Ukraine", "Portugal"]
"leagueTitleCountries": ["Ukraine", "Portugal"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "van-dijk",
@@ -81,7 +111,12 @@
"formerClubs": ["Groningen", "Celtic", "Southampton"],
"championsLeagueTitles": 1,
"leagueTitles": 2,
"leagueTitleCountries": ["Scotland", "England"]
"leagueTitleCountries": ["Scotland", "England"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "dias",
@@ -93,7 +128,12 @@
"formerClubs": ["Benfica"],
"championsLeagueTitles": 1,
"leagueTitles": 5,
"leagueTitleCountries": ["Portugal", "England"]
"leagueTitleCountries": ["Portugal", "England"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "rudiger",
@@ -105,7 +145,12 @@
"formerClubs": ["Stuttgart", "Roma", "Chelsea"],
"championsLeagueTitles": 2,
"leagueTitles": 1,
"leagueTitleCountries": ["Spain"]
"leagueTitleCountries": ["Spain"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "saliba",
@@ -117,7 +162,12 @@
"formerClubs": ["Saint-Etienne", "Nice", "Marseille"],
"championsLeagueTitles": 0,
"leagueTitles": 0,
"leagueTitleCountries": []
"leagueTitleCountries": [],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "pavard",
@@ -129,7 +179,12 @@
"formerClubs": ["Lille", "Stuttgart", "Bayern Munich"],
"championsLeagueTitles": 1,
"leagueTitles": 4,
"leagueTitleCountries": ["Germany", "Italy"]
"leagueTitleCountries": ["Germany", "Italy"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": true,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "grimaldo",
@@ -141,7 +196,12 @@
"formerClubs": ["Barcelona B", "Benfica"],
"championsLeagueTitles": 0,
"leagueTitles": 5,
"leagueTitleCountries": ["Portugal", "Germany"]
"leagueTitleCountries": ["Portugal", "Germany"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": true
},
{
"id": "bastoni",
@@ -153,7 +213,12 @@
"formerClubs": ["Atalanta", "Parma"],
"championsLeagueTitles": 0,
"leagueTitles": 2,
"leagueTitleCountries": ["Italy"]
"leagueTitleCountries": ["Italy"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": true
},
{
"id": "calafiori",
@@ -165,7 +230,12 @@
"formerClubs": ["Roma", "Basel", "Bologna"],
"championsLeagueTitles": 0,
"leagueTitles": 1,
"leagueTitleCountries": ["Switzerland"]
"leagueTitleCountries": ["Switzerland"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "rodri",
@@ -177,7 +247,12 @@
"formerClubs": ["Villarreal", "Atletico Madrid"],
"championsLeagueTitles": 1,
"leagueTitles": 5,
"leagueTitleCountries": ["England", "Spain"]
"leagueTitleCountries": ["England", "Spain"],
"ballonDOr": 1,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": true
},
{
"id": "bellingham",
@@ -189,7 +264,12 @@
"formerClubs": ["Birmingham City", "Borussia Dortmund"],
"championsLeagueTitles": 1,
"leagueTitles": 1,
"leagueTitleCountries": ["Spain"]
"leagueTitleCountries": ["Spain"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "modric",
@@ -201,7 +281,12 @@
"formerClubs": ["Dinamo Zagreb", "Tottenham Hotspur", "Real Madrid"],
"championsLeagueTitles": 6,
"leagueTitles": 6,
"leagueTitleCountries": ["Croatia", "Spain"]
"leagueTitleCountries": ["Croatia", "Spain"],
"ballonDOr": 1,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "odegaard",
@@ -213,7 +298,12 @@
"formerClubs": ["Stromsgodset", "Real Madrid", "Real Sociedad"],
"championsLeagueTitles": 0,
"leagueTitles": 1,
"leagueTitleCountries": ["Netherlands"]
"leagueTitleCountries": ["Netherlands"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "de-bruyne",
@@ -225,7 +315,12 @@
"formerClubs": ["Genk", "Chelsea", "Werder Bremen", "Wolfsburg", "Manchester City"],
"championsLeagueTitles": 1,
"leagueTitles": 7,
"leagueTitleCountries": ["Belgium", "England"]
"leagueTitleCountries": ["Belgium", "England"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "szoboszlai",
@@ -237,7 +332,12 @@
"formerClubs": ["FC Liefering", "Red Bull Salzburg", "RB Leipzig"],
"championsLeagueTitles": 0,
"leagueTitles": 6,
"leagueTitleCountries": ["Austria", "Germany", "England"]
"leagueTitleCountries": ["Austria", "Germany", "England"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "fabian-ruiz",
@@ -249,7 +349,12 @@
"formerClubs": ["Real Betis", "Napoli"],
"championsLeagueTitles": 0,
"leagueTitles": 2,
"leagueTitleCountries": ["Italy", "France"]
"leagueTitleCountries": ["Italy", "France"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": true
},
{
"id": "vitinha",
@@ -261,7 +366,12 @@
"formerClubs": ["Porto", "Wolverhampton Wanderers"],
"championsLeagueTitles": 0,
"leagueTitles": 3,
"leagueTitleCountries": ["Portugal", "France"]
"leagueTitleCountries": ["Portugal", "France"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "joao-neves",
@@ -273,7 +383,12 @@
"formerClubs": ["Benfica"],
"championsLeagueTitles": 0,
"leagueTitles": 1,
"leagueTitleCountries": ["France"]
"leagueTitleCountries": ["France"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "saka",
@@ -285,7 +400,12 @@
"formerClubs": [],
"championsLeagueTitles": 0,
"leagueTitles": 0,
"leagueTitleCountries": []
"leagueTitleCountries": [],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "vinicius",
@@ -297,7 +417,12 @@
"formerClubs": ["Flamengo"],
"championsLeagueTitles": 2,
"leagueTitles": 3,
"leagueTitleCountries": ["Spain"]
"leagueTitleCountries": ["Spain"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": true,
"eurocupWinner": false
},
{
"id": "salah",
@@ -309,7 +434,12 @@
"formerClubs": ["Basel", "Chelsea", "Fiorentina", "Roma"],
"championsLeagueTitles": 1,
"leagueTitles": 3,
"leagueTitleCountries": ["Switzerland", "England"]
"leagueTitleCountries": ["Switzerland", "England"],
"ballonDOr": 0,
"goldenBoot": 3,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "mbappe",
@@ -321,7 +451,12 @@
"formerClubs": ["Monaco", "Paris Saint-Germain"],
"championsLeagueTitles": 1,
"leagueTitles": 7,
"leagueTitleCountries": ["France", "Spain"]
"leagueTitleCountries": ["France", "Spain"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": true,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "coman",
@@ -333,7 +468,12 @@
"formerClubs": ["Paris Saint-Germain", "Juventus"],
"championsLeagueTitles": 1,
"leagueTitles": 12,
"leagueTitleCountries": ["France", "Italy", "Germany"]
"leagueTitleCountries": ["France", "Italy", "Germany"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": true,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "kvaratskhelia",
@@ -345,7 +485,12 @@
"formerClubs": ["Dinamo Batumi", "Rubin Kazan", "Napoli"],
"championsLeagueTitles": 0,
"leagueTitles": 2,
"leagueTitleCountries": ["Italy", "France"]
"leagueTitleCountries": ["Italy", "France"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "olise",
@@ -357,7 +502,12 @@
"formerClubs": ["Reading", "Crystal Palace"],
"championsLeagueTitles": 0,
"leagueTitles": 1,
"leagueTitleCountries": ["Germany"]
"leagueTitleCountries": ["Germany"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "barcola",
@@ -369,7 +519,12 @@
"formerClubs": ["Lyon"],
"championsLeagueTitles": 0,
"leagueTitles": 2,
"leagueTitleCountries": ["France"]
"leagueTitleCountries": ["France"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "haaland",
@@ -381,7 +536,12 @@
"formerClubs": ["Molde", "Red Bull Salzburg", "Borussia Dortmund"],
"championsLeagueTitles": 1,
"leagueTitles": 4,
"leagueTitleCountries": ["Austria", "England"]
"leagueTitleCountries": ["Austria", "England"],
"ballonDOr": 0,
"goldenBoot": 1,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "kane",
@@ -393,7 +553,12 @@
"formerClubs": ["Tottenham Hotspur", "Leicester City", "Norwich City"],
"championsLeagueTitles": 0,
"leagueTitles": 1,
"leagueTitleCountries": ["Germany"]
"leagueTitleCountries": ["Germany"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "lewandowski",
@@ -405,7 +570,12 @@
"formerClubs": ["Lech Poznan", "Borussia Dortmund", "Bayern Munich"],
"championsLeagueTitles": 1,
"leagueTitles": 11,
"leagueTitleCountries": ["Poland", "Germany", "Spain"]
"leagueTitleCountries": ["Poland", "Germany", "Spain"],
"ballonDOr": 0,
"goldenBoot": 2,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "lautaro",
@@ -417,7 +587,12 @@
"formerClubs": ["Racing Club"],
"championsLeagueTitles": 0,
"leagueTitles": 2,
"leagueTitleCountries": ["Italy"]
"leagueTitleCountries": ["Italy"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": true,
"copaAmericaWinner": true,
"eurocupWinner": false
},
{
"id": "morata",
@@ -429,7 +604,12 @@
"formerClubs": ["Real Madrid", "Juventus", "Chelsea", "Atletico Madrid", "AC Milan"],
"championsLeagueTitles": 2,
"leagueTitles": 4,
"leagueTitleCountries": ["Spain", "Italy"]
"leagueTitleCountries": ["Spain", "Italy"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": true
},
{
"id": "jonathan-david",
@@ -441,7 +621,12 @@
"formerClubs": ["Gent", "Lille"],
"championsLeagueTitles": 0,
"leagueTitles": 1,
"leagueTitleCountries": ["France"]
"leagueTitleCountries": ["France"],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "gyokeres",
@@ -453,7 +638,12 @@
"formerClubs": ["Brommapojkarna", "Coventry City", "Sporting CP"],
"championsLeagueTitles": 0,
"leagueTitles": 1,
"leagueTitleCountries": ["Portugal"]
"leagueTitleCountries": ["Portugal"],
"ballonDOr": 0,
"goldenBoot": 1,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
},
{
"id": "retegui",
@@ -465,6 +655,11 @@
"formerClubs": ["Tigre", "Genoa"],
"championsLeagueTitles": 0,
"leagueTitles": 0,
"leagueTitleCountries": []
"leagueTitleCountries": [],
"ballonDOr": 0,
"goldenBoot": 0,
"worldCupWinner": false,
"copaAmericaWinner": false,
"eurocupWinner": false
}
]
+3845 -3845
View File
File diff suppressed because it is too large Load Diff
+122 -19
View File
@@ -191,7 +191,7 @@ const dictionaries: Record<Locale, Dictionary> = {
allCompleted: "Todos los puzzles completados.",
available: "disponible",
browsePlayers: (position) => `Ver todos los jugadores ${position}`,
close: "Cerrar",
close: "Cerca",
closeModal: "Cerrar lista de jugadores",
copied: "Copiado",
copy: "Copiar",
@@ -277,13 +277,13 @@ const dictionaries: Record<Locale, Dictionary> = {
"Four players play in England.": "Cuatro jugadores juegan en Inglaterra.",
"The striker is Norwegian.": "El delantero es noruego.",
"The winger is English.": "El extremo es inglés.",
"The goalkeeper and striker have both won league titles in Spain.": "El portero y el delantero han ganado ligas en España.",
"The defender and winger have both won league titles in Germany.": "El defensa y el extremo han ganado ligas en Alemania.",
"The goalkeeper and striker have both won league titles in Spain.": "El portero y el delantero han ganado campeonatos en España.",
"The defender and winger have both won league titles in Germany.": "El defensa y el extremo han ganado campeonatos en Alemania.",
"The winger and striker were once teammates at Juventus.": "El extremo y el delantero coincidieron en la Juventus.",
"The midfielder has played league football in Austria, Germany and England.": "El centrocampista ha jugado liga en Austria, Alemania e Inglaterra.",
"The goalkeeper and defender have both won league titles in Portugal.": "El portero y el defensa han ganado ligas en Portugal.",
"The defender has won league titles in Portugal and Germany.": "El defensa ha ganado ligas en Portugal y Alemania.",
"The midfielder has won league titles in Italy and France.": "El centrocampista ha ganado ligas en Italia y Francia.",
"The midfielder has played league football in Austria, Germany and England.": "El centrocampista ha jugado en liga en Austria, Alemania e Inglaterra.",
"The goalkeeper and defender have both won league titles in Portugal.": "El portero y el defensa han ganado campeonatos en Portugal.",
"The defender has won league titles in Portugal and Germany.": "El defensa ha ganado campeonatos en Portugal y Alemania.",
"The midfielder has won league titles in Italy and France.": "El centrocampista ha ganado campeonatos en Italia y Francia.",
"The winger and midfielder both moved to France after starring in Italy.": "El extremo y el centrocampista llegaron a Francia después de destacar en Italia.",
"The striker was the top scorer in France before moving to Italy.": "El delantero fue máximo goleador en Francia antes de irse a Italia.",
},
@@ -292,7 +292,7 @@ const dictionaries: Record<Locale, Dictionary> = {
allCompleted: "Tous les puzzles sont terminés.",
available: "disponible",
browsePlayers: (position) => `Voir tous les joueurs ${position}`,
close: "Fermer",
close: "Proche",
closeModal: "Fermer la liste des joueurs",
copied: "Copié",
copy: "Copier",
@@ -379,7 +379,7 @@ const dictionaries: Record<Locale, Dictionary> = {
allCompleted: "Alle Rätsel abgeschlossen.",
available: "verfügbar",
browsePlayers: (position) => `Alle ${position}-Spieler anzeigen`,
close: "Schliessen",
close: "Nah",
closeModal: "Spielerliste schließen",
copied: "Kopiert",
copy: "Kopieren",
@@ -466,7 +466,7 @@ const dictionaries: Record<Locale, Dictionary> = {
allCompleted: "Tutti i puzzle completati.",
available: "disponibile",
browsePlayers: (position) => `Vedi tutti i giocatori ${position}`,
close: "Chiudi",
close: "Vicino",
closeModal: "Chiudi lista giocatori",
copied: "Copiato",
copy: "Copia",
@@ -553,7 +553,7 @@ const dictionaries: Record<Locale, Dictionary> = {
allCompleted: "Todos os puzzles concluídos.",
available: "disponível",
browsePlayers: (position) => `Ver todos os jogadores ${position}`,
close: "Fechar",
close: "Perto",
closeModal: "Fechar lista de jogadores",
copied: "Copiado",
copy: "Copiar",
@@ -674,6 +674,46 @@ function translateGeneratedClue(clue: string, locale: Locale): string {
return clue;
}
const playsFor = clue.match(/^The (.+) plays for (.+)\.$/);
if (playsFor) {
return tPlaysFor(locale, playsFor[1], playsFor[2]);
}
const wonWorldCup = clue.match(/^The (.+) has won the World Cup\.$/);
if (wonWorldCup) {
return tWonWorldCup(locale, wonWorldCup[1]);
}
const wonEuro = clue.match(/^The (.+) has won the European Championship\.$/);
if (wonEuro) {
return tWonEuro(locale, wonEuro[1]);
}
const wonCopa = clue.match(/^The (.+) has won the Copa América\.$/);
if (wonCopa) {
return tWonCopaAmerica(locale, wonCopa[1]);
}
const wonBallonDOr = clue.match(/^The (.+) has won the Ballon d'Or\.$/);
if (wonBallonDOr) {
return tWonBallonDOr(locale, wonBallonDOr[1]);
}
const wonGoldenBootMultiple = clue.match(/^The (.+) has won (\d+) European Golden Boots\.$/);
if (wonGoldenBootMultiple) {
return tWonGoldenBoots(locale, wonGoldenBootMultiple[1], Number(wonGoldenBootMultiple[2]));
}
const wonGoldenBoot = clue.match(/^The (.+) has won the European Golden Boot\.$/);
if (wonGoldenBoot) {
return tWonGoldenBoots(locale, wonGoldenBoot[1], 1);
}
const playForSameClub = clue.match(/^The (.+) and (.+) play for the same club\.$/);
if (playForSameClub) {
return tPlayForSameClub(locale, playForSameClub[1], playForSameClub[2]);
}
const sharedClub = clue.match(/^(\d+|Two) players share the same club\.$/);
if (sharedClub) {
const count = sharedClub[1] === "Two" ? 2 : Number(sharedClub[1]);
@@ -994,7 +1034,7 @@ function tSameChampions(locale: Locale, left: string, right: string): string {
}
function tWonLeagueTitles(locale: Locale, position: string, count: number): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado ${count} ${count === 1 ? "liga" : "ligas"}.`;
if (locale === "es") return `El ${pos(locale, position)} ha ganado ${count} ${count === 1 ? "campeonato" : "campeonatos"}.`;
if (locale === "fr") return `Le ${pos(locale, position)} a gagné ${count} ${count === 1 ? "championnat" : "championnats"}.`;
if (locale === "de") return `Der ${pos(locale, position)} hat ${count} Ligatitel gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto ${count} ${count === 1 ? "campionato" : "campionati"}.`;
@@ -1002,7 +1042,7 @@ function tWonLeagueTitles(locale: Locale, position: string, count: number): stri
}
function tNoLeagueTitle(locale: Locale, position: string): string {
if (locale === "es") return `El ${pos(locale, position)} no ha ganado ninguna liga.`;
if (locale === "es") return `El ${pos(locale, position)} no ha ganado ningún campeonato.`;
if (locale === "fr") return `Le ${pos(locale, position)} n'a gagné aucun championnat.`;
if (locale === "de") return `Der ${pos(locale, position)} hat keinen Ligatitel gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} non ha vinto alcun campionato.`;
@@ -1010,7 +1050,7 @@ function tNoLeagueTitle(locale: Locale, position: string): string {
}
function tAtMostLeagueTitles(locale: Locale, position: string, count: number): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado como máximo ${count} ligas.`;
if (locale === "es") return `El ${pos(locale, position)} ha ganado como máximo ${count} campeonatos.`;
if (locale === "fr") return `Le ${pos(locale, position)} a gagné au maximum ${count} championnats.`;
if (locale === "de") return `Der ${pos(locale, position)} hat höchstens ${count} Ligatitel gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto al massimo ${count} campionati.`;
@@ -1018,7 +1058,7 @@ function tAtMostLeagueTitles(locale: Locale, position: string, count: number): s
}
function tBetweenLeagueTitles(locale: Locale, position: string, min: number, max: number): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado entre ${min} y ${max} ligas.`;
if (locale === "es") return `El ${pos(locale, position)} ha ganado entre ${min} y ${max} campeonatos.`;
if (locale === "fr") return `Le ${pos(locale, position)} a gagné entre ${min} et ${max} championnats.`;
if (locale === "de") return `Der ${pos(locale, position)} hat zwischen ${min} und ${max} Ligatitel gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto tra ${min} e ${max} campionati.`;
@@ -1026,7 +1066,7 @@ function tBetweenLeagueTitles(locale: Locale, position: string, min: number, max
}
function tMoreThanLeagueTitles(locale: Locale, position: string, count: number): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado más de ${count} ligas.`;
if (locale === "es") return `El ${pos(locale, position)} ha ganado más de ${count} campeonatos.`;
if (locale === "fr") return `Le ${pos(locale, position)} a gagné plus de ${count} championnats.`;
if (locale === "de") return `Der ${pos(locale, position)} hat mehr als ${count} Ligatitel gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto più di ${count} campionati.`;
@@ -1042,7 +1082,7 @@ function tWonChampionsTitles(locale: Locale, position: string, count: number): s
}
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 === "es") return `El ${pos(locale, position)} ha ganado campeonatos en ${countries(locale, value)}.`;
if (locale === "fr") return `Le ${pos(locale, position)} a gagné des championnats en ${countries(locale, value)}.`;
if (locale === "de") return `Der ${pos(locale, position)} hat Ligatitel in ${countries(locale, value)} gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto campionati in ${countries(locale, value)}.`;
@@ -1066,7 +1106,7 @@ function tTeammates(locale: Locale, left: string, right: string, club: string):
}
function tSameLeagueTitles(locale: Locale, left: string, right: string): string {
if (locale === "es") return `El ${pos(locale, left)} y el ${pos(locale, right)} han ganado el mismo número de ligas.`;
if (locale === "es") return `El ${pos(locale, left)} y el ${pos(locale, right)} han ganado el mismo número de campeonatos.`;
if (locale === "fr") return `Le ${pos(locale, left)} et le ${pos(locale, right)} ont gagné le même nombre de championnats.`;
if (locale === "de") return `Der ${pos(locale, left)} und der ${pos(locale, right)} haben gleich viele Ligatitel gewonnen.`;
if (locale === "it") return `Il ${pos(locale, left)} e il ${pos(locale, right)} hanno vinto lo stesso numero di campionati.`;
@@ -1074,7 +1114,7 @@ function tSameLeagueTitles(locale: Locale, left: string, right: string): string
}
function tMoreLeagueTitles(locale: Locale, left: string, right: string): string {
if (locale === "es") return `El ${pos(locale, left)} tiene más ligas que el ${pos(locale, right)}.`;
if (locale === "es") return `El ${pos(locale, left)} tiene más campeonatos que el ${pos(locale, right)}.`;
if (locale === "fr") return `Le ${pos(locale, left)} a plus de championnats que le ${pos(locale, right)}.`;
if (locale === "de") return `Der ${pos(locale, left)} hat mehr Ligatitel als der ${pos(locale, right)}.`;
if (locale === "it") return `Il ${pos(locale, left)} ha più campionati del ${pos(locale, right)}.`;
@@ -1088,3 +1128,66 @@ function tNationality(locale: Locale, position: string, country: string): string
if (locale === "it") return `Il ${pos(locale, position)} viene da ${countries(locale, country)}.`;
return `O ${pos(locale, position)} é de ${countries(locale, country)}.`;
}
function tPlaysFor(locale: Locale, position: string, club: string): string {
if (locale === "es") return `El ${pos(locale, position)} juega en el ${club}.`;
if (locale === "fr") return `Le ${pos(locale, position)} joue au ${club}.`;
if (locale === "de") return `Der ${pos(locale, position)} spielt für den ${club}.`;
if (locale === "it") return `Il ${pos(locale, position)} gioca nell'${club}.`;
return `O ${pos(locale, position)} joga no ${club}.`;
}
function tWonWorldCup(locale: Locale, position: string): string {
if (locale === "es") return `El ${pos(locale, position)} es campeón del mundo.`;
if (locale === "fr") return `Le ${pos(locale, position)} est champion du monde.`;
if (locale === "de") return `Der ${pos(locale, position)} ist Weltmeister.`;
if (locale === "it") return `Il ${pos(locale, position)} è campione del mondo.`;
return `O ${pos(locale, position)} é campeão do mundo.`;
}
function tWonEuro(locale: Locale, position: string): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado la Eurocopa.`;
if (locale === "fr") return `Le ${pos(locale, position)} a remporté le Championnat d'Europe.`;
if (locale === "de") return `Der ${pos(locale, position)} hat die Europameisterschaft gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto il Campionato Europeo.`;
return `O ${pos(locale, position)} ganhou o Campeonato da Europa.`;
}
function tWonCopaAmerica(locale: Locale, position: string): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado la Copa América.`;
if (locale === "fr") return `Le ${pos(locale, position)} a remporté la Copa América.`;
if (locale === "de") return `Der ${pos(locale, position)} hat die Copa América gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto la Copa América.`;
return `O ${pos(locale, position)} ganhou a Copa América.`;
}
function tWonBallonDOr(locale: Locale, position: string): string {
if (locale === "es") return `El ${pos(locale, position)} ha ganado el Balón de Oro.`;
if (locale === "fr") return `Le ${pos(locale, position)} a remporté le Ballon d'Or.`;
if (locale === "de") return `Der ${pos(locale, position)} hat den Ballon d'Or gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto il Pallone d'Oro.`;
return `O ${pos(locale, position)} ganhou a Bola de Ouro.`;
}
function tWonGoldenBoots(locale: Locale, position: string, count: number): string {
if (count === 1) {
if (locale === "es") return `El ${pos(locale, position)} ha ganado la Bota de Oro europea.`;
if (locale === "fr") return `Le ${pos(locale, position)} a remporté le Soulier d'Or européen.`;
if (locale === "de") return `Der ${pos(locale, position)} hat den Europäischen Goldenen Schuh gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto la Scarpa d'Oro europea.`;
return `O ${pos(locale, position)} ganhou a Chuteira de Ouro europeia.`;
}
if (locale === "es") return `El ${pos(locale, position)} ha ganado ${count} Botas de Oro europeas.`;
if (locale === "fr") return `Le ${pos(locale, position)} a remporté ${count} Souliers d'Or européens.`;
if (locale === "de") return `Der ${pos(locale, position)} hat ${count} Europäische Goldene Schuhe gewonnen.`;
if (locale === "it") return `Il ${pos(locale, position)} ha vinto ${count} Scarpe d'Oro europee.`;
return `O ${pos(locale, position)} ganhou ${count} Chuteiras de Ouro europeias.`;
}
function tPlayForSameClub(locale: Locale, left: string, right: string): string {
if (locale === "es") return `El ${pos(locale, left)} y el ${pos(locale, right)} juegan en el mismo club.`;
if (locale === "fr") return `Le ${pos(locale, left)} et le ${pos(locale, right)} jouent dans le même club.`;
if (locale === "de") return `Der ${pos(locale, left)} und der ${pos(locale, right)} spielen beim selben Klub.`;
if (locale === "it") return `Il ${pos(locale, left)} e il ${pos(locale, right)} giocano nello stesso club.`;
return `O ${pos(locale, left)} e o ${pos(locale, right)} jogam no mesmo clube.`;
}
+218 -165
View File
@@ -15,11 +15,13 @@ const nationalityRegions = {
Belgium: "Europe",
Brazil: "South America",
Canada: "North America",
Croatia: "Europe",
Egypt: "Africa",
England: "Europe",
France: "Europe",
Georgia: "Europe",
Germany: "Europe",
Hungary: "Europe",
Italy: "Europe",
Netherlands: "Europe",
Norway: "Europe",
@@ -27,8 +29,24 @@ const nationalityRegions = {
Portugal: "Europe",
Spain: "Europe",
Sweden: "Europe",
Switzerland: "Europe",
Ukraine: "Europe",
};
// La Liga players and top globally-famous players are "easy" (0).
// Premier League and other well-known players are "medium" (1).
// Everyone else is "hard" (2).
const EASY_PLAYER_IDS = new Set([
"courtois", "vinicius", "mbappe", "bellingham", "rudiger", "lewandowski",
"haaland", "salah", "de-bruyne", "van-dijk", "modric", "alisson", "ederson", "kane",
]);
function playerDifficulty(player) {
if (EASY_PLAYER_IDS.has(player.id) || player.league === "LaLiga") return 0;
if (["Premier League", "Bundesliga", "Serie A", "Ligue 1"].includes(player.league)) return 1;
return 2;
}
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));
@@ -91,47 +109,107 @@ function lineupFromIndex(index) {
}
function buildClues(lineup, difficulty, fixedPosition) {
const hiddenPositions = POSITIONS.filter((position) => position !== fixedPosition);
const hiddenPositions = POSITIONS.filter((p) => p !== fixedPosition);
if (difficulty === "easy") {
return limitClues(
ensureCoverage(
hiddenPositions.flatMap((position, index) => [
clue(index % 2 === 0 ? buildCurrentLeagueClue(position, lineup[position]) : buildNationalityClue(position, lineup[position]), "identity"),
clue(buildTitleCountOrCountryClue(lineup[position], subjectFor(position)), "titles"),
]),
hiddenPositions,
),
5,
);
}
if (difficulty === "easy") return buildEasyClues(lineup, hiddenPositions);
if (difficulty === "medium") return buildMediumClues(lineup, hiddenPositions);
return buildHardClues(lineup, hiddenPositions);
}
if (difficulty === "medium") {
return limitClues(
ensureCoverage(
[
...buildMediumPairClues(lineup, hiddenPositions),
...hiddenPositions.map((position) => clue(buildNationalityRegionClue(position, lineup[position]), "nationality")),
...hiddenPositions.map((position) => clue(buildCurrentLeagueClue(position, lineup[position]), "league")),
...hiddenPositions.map((position) => clue(buildLeagueTitleBucketClue(lineup[position], subjectFor(position)), "titles")),
],
hiddenPositions,
),
5,
);
}
// Easy: all 4 clues are specific and direct. Rotate through 4 distinct easy types.
// Each position gets a different type so no repetition. A 5th title count clue is added.
function buildEasyClues(lineup, hiddenPositions) {
// Type rotation per position index — ensures diversity
const typeBuilders = [
(pos, player) => clue(buildCurrentLeagueClue(pos, player), "league"),
(pos, player) => clue(buildNationalityClue(pos, player), "nationality"),
(pos, player) => clue(buildClubClue(pos, player), "club"),
(pos, player) => clue(buildTournamentWinnerClue(player, subjectFor(pos)) ?? buildCurrentLeagueClue(pos, player), "trophy"),
];
const primaryClues = hiddenPositions.map((pos, i) => typeBuilders[i % typeBuilders.length](pos, lineup[pos]));
// Add title count and extra league/nationality clues as fill and backup coverage
const fillClues = hiddenPositions.flatMap((pos) => {
const player = lineup[pos];
const subject = subjectFor(pos);
return [
clue(buildTitleCountOrCountryClue(player, subject), "titles"),
clue(buildCurrentLeagueClue(pos, player), "league"),
clue(buildNationalityClue(pos, player), "nationality"),
];
});
return limitClues(ensureCoverage(dedupeClues([...primaryClues, ...fillClues]), hiddenPositions), 5);
}
// Medium: 2 positions get easy/direct clues, 2 get medium clues.
// Sort positions by player difficulty — easiest players get direct clues.
function buildMediumClues(lineup, hiddenPositions) {
const sorted = [...hiddenPositions].sort((a, b) => playerDifficulty(lineup[a]) - playerDifficulty(lineup[b]));
const easyPositions = sorted.slice(0, 2);
const harderPositions = sorted.slice(2);
const easyClues = easyPositions.flatMap((pos) => {
const player = lineup[pos];
const subject = subjectFor(pos);
return [
clue(buildCurrentLeagueClue(pos, player), "league"),
clue(buildNationalityClue(pos, player), "nationality"),
clue(buildClubClue(pos, player), "club"),
];
});
const mediumClues = harderPositions.flatMap((pos) => {
const player = lineup[pos];
const subject = subjectFor(pos);
return [
clue(buildNationalityRegionClue(pos, player), "nationality"),
clue(buildLeagueTitleBucketClue(player, subject), "titles"),
clue(buildAwardClue(player, subject), "award"),
clue(buildChampionsLeagueCountClue(player, subject), "champions"),
clue(buildCurrentLeagueClue(pos, player), "league"),
];
});
// Pair clues add cross-position info
const pairClues = buildMediumPairClues(lineup, hiddenPositions);
return limitClues(ensureCoverage(dedupeClues([...easyClues, ...mediumClues, ...pairClues]), hiddenPositions), 5);
}
// Hard: 1 easy clue for the easiest player, 2 medium clues for the middle, hard comparisons for the rest.
function buildHardClues(lineup, hiddenPositions) {
const sorted = [...hiddenPositions].sort((a, b) => playerDifficulty(lineup[a]) - playerDifficulty(lineup[b]));
const easiest = sorted[0];
const middle = sorted.slice(1, 3);
const hardest = sorted[sorted.length - 1];
const easyClueLine = [
clue(buildNationalityClue(easiest, lineup[easiest]), "nationality"),
];
const mediumLines = middle.flatMap((pos) => {
const player = lineup[pos];
const subject = subjectFor(pos);
return [
clue(buildNationalityRegionClue(pos, player), "nationality"),
clue(buildLeagueFamilyClue(pos, player), "league"),
clue(buildAwardClue(player, subject), "award"),
clue(buildLeagueTitleBucketClue(player, subject), "titles"),
];
});
// Hardest player only gets comparisons — must piece together from context
const hardLines = [
clue(buildLeagueTitleBucketClue(lineup[hardest], subjectFor(hardest)), "titles"),
clue(buildChampionsLeagueCountClue(lineup[hardest], subjectFor(hardest)), "champions"),
];
const pairClues = buildHardPairClues(lineup, hiddenPositions);
return limitClues(
ensureCoverage(
[
...buildHardPairClues(lineup, hiddenPositions),
...hiddenPositions.map((position) => clue(buildNationalityRegionClue(position, lineup[position]), "nationality")),
...hiddenPositions.map((position) => clue(buildLeagueFamilyClue(position, lineup[position]), "league")),
...hiddenPositions.map((position) => clue(buildTitleCountryPairClue(lineup[position], subjectFor(position)), "titles")),
...hiddenPositions.map((position) => clue(buildChampionsLeagueCountClue(lineup[position], subjectFor(position)), "champions")),
],
hiddenPositions,
),
ensureCoverage(dedupeClues([...easyClueLine, ...mediumLines, ...hardLines, ...pairClues]), hiddenPositions),
6,
);
}
@@ -140,6 +218,10 @@ function subjectFor(position) {
return `The ${positionLabels[position]}`;
}
function buildClubClue(position, player) {
return `${subjectFor(position)} plays for ${player.club}.`;
}
function buildNationalityClue(position, player) {
return `${subjectFor(position)} is from ${player.nationality}.`;
}
@@ -161,12 +243,24 @@ function buildLeagueFamilyClue(position, player) {
return `${subjectFor(position)} plays outside the top five leagues.`;
}
function buildTitleCountryClue(player, subject) {
const country = player.leagueTitleCountries?.[0];
if (country) {
return `${subject} has won league titles in ${country}.`;
}
return `${subject} has won ${player.leagueTitles} league titles.`;
// Returns a specific tournament win clue, or null if the player has no major win.
function buildTournamentWinnerClue(player, subject) {
if (player.worldCupWinner) return `${subject} has won the World Cup.`;
if (player.eurocupWinner) return `${subject} has won the European Championship.`;
if (player.copaAmericaWinner) return `${subject} has won the Copa América.`;
return null;
}
// Award clue: Ballon d'Or or Golden Boot count, or Champions League count as fallback.
function buildAwardClue(player, subject) {
if (player.ballonDOr > 0) return `${subject} has won the Ballon d'Or.`;
if (player.goldenBoot > 1) return `${subject} has won ${player.goldenBoot} European Golden Boots.`;
if (player.goldenBoot === 1) return `${subject} has won the European Golden Boot.`;
return buildChampionsLeagueCountClue(player, subject);
}
function buildChampionsLeagueCountClue(player, subject) {
return `${subject} has won ${player.championsLeagueTitles} Champions League title${player.championsLeagueTitles === 1 ? "" : "s"}.`;
}
function buildTitleCountOrCountryClue(player, subject) {
@@ -176,81 +270,9 @@ function buildTitleCountOrCountryClue(player, subject) {
return buildLeagueTitleCountClue(player, subject);
}
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);
}
function buildFormerClubClue(playerA, playerB, subject) {
const common = (playerA.formerClubs ?? []).find((club) => (playerB.formerClubs ?? []).includes(club));
if (common) {
return `${subject} were once teammates at ${common}.`;
}
if ((playerA.championsLeagueTitles ?? 0) === (playerB.championsLeagueTitles ?? 0)) {
return `${subject} have won the same number of Champions League titles.`;
}
if ((playerA.leagueTitles ?? 0) === (playerB.leagueTitles ?? 0)) {
return `${subject} have won the same number of league titles.`;
}
const firstCountry = playerA.leagueTitleCountries?.[0] ?? null;
if (firstCountry) {
return `${subject.split(" and ")[0]} has won league titles in ${firstCountry}.`;
}
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(clue(buildFormerClubClue(lineup[left], lineup[right], `${subjectFor(left)} and ${positionLabels[right]}`), "history"));
clues.push(clue(buildLeagueTitleComparisonClue(left, lineup[left], right, lineup[right]), "comparison"));
clues.push(clue(buildChampionsLeagueComparisonClue(left, lineup[left], right, lineup[right]), "champions"));
}
}
return dedupeClues(clues);
}
function buildMediumPairClues(lineup, hiddenPositions) {
const pairs = [];
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];
pairs.push(clue(buildFormerClubClue(lineup[left], lineup[right], `${subjectFor(left)} and ${positionLabels[right]}`), "history"));
pairs.push(clue(buildLeagueTitleComparisonClue(left, lineup[left], right, lineup[right]), "comparison"));
}
}
return dedupeClues(pairs);
}
function buildHardPairClues(lineup, hiddenPositions) {
const pairs = [];
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];
pairs.push(clue(buildFormerClubClue(lineup[left], lineup[right], `${subjectFor(left)} and ${positionLabels[right]}`), "history"));
pairs.push(clue(buildChampionsLeagueComparisonClue(left, lineup[left], right, lineup[right]), "champions"));
pairs.push(clue(buildLeagueTitleComparisonClue(left, lineup[left], right, lineup[right]), "comparison"));
}
}
return dedupeClues(pairs);
}
function buildTitleCountryPairClue(player, subject) {
const countries = player.leagueTitleCountries ?? [];
if (countries.length >= 2) {
return `${subject} has won league titles in ${countries.slice(0, 2).join(" and ")}.`;
}
if (countries.length === 1) {
return `${subject} has won league titles in ${countries[0]}.`;
}
function buildTitleCountryClue(player, subject) {
const country = player.leagueTitleCountries?.[0];
if (country) return `${subject} has won league titles in ${country}.`;
return buildLeagueTitleCountClue(player, subject);
}
@@ -265,12 +287,32 @@ function buildLeagueTitleBucketClue(player, subject) {
return `${subject} has won more than 5 league titles.`;
}
function buildTitleCountryPairClue(player, subject) {
const countries = player.leagueTitleCountries ?? [];
if (countries.length >= 2) return `${subject} has won league titles in ${countries.slice(0, 2).join(" and ")}.`;
if (countries.length === 1) return `${subject} has won league titles in ${countries[0]}.`;
return buildLeagueTitleCountClue(player, subject);
}
function buildFormerClubClue(playerA, playerB, subject) {
const common = (playerA.formerClubs ?? []).find((club) => (playerB.formerClubs ?? []).includes(club));
if (common) return `${subject} were once teammates at ${common}.`;
if (playerA.club === playerB.club) return `${subject} play for the same club.`;
if ((playerA.championsLeagueTitles ?? 0) === (playerB.championsLeagueTitles ?? 0)) {
return `${subject} have won the same number of Champions League titles.`;
}
if ((playerA.leagueTitles ?? 0) === (playerB.leagueTitles ?? 0)) {
return `${subject} have won the same number of league titles.`;
}
const firstCountry = playerA.leagueTitleCountries?.[0] ?? null;
if (firstCountry) return `${subject.split(" and ")[0]} has won league titles in ${firstCountry}.`;
return buildLeagueTitleCountClue(playerA, subject.split(" and ")[0]);
}
function buildLeagueTitleComparisonClue(positionA, playerA, positionB, playerB) {
const subjectA = subjectFor(positionA);
const subjectB = `the ${positionLabels[positionB]}`;
if (playerA.leagueTitles > playerB.leagueTitles) {
return `${subjectA} has more league titles than ${subjectB}.`;
}
if (playerA.leagueTitles > playerB.leagueTitles) return `${subjectA} has more league titles than ${subjectB}.`;
if (playerB.leagueTitles > playerA.leagueTitles) {
return `${subjectFor(positionB)} has more league titles than the ${positionLabels[positionA]}.`;
}
@@ -289,6 +331,35 @@ function buildChampionsLeagueComparisonClue(positionA, playerA, positionB, playe
return `${subjectA} and ${positionLabels[positionB]} have won the same number of Champions League titles.`;
}
function buildMediumPairClues(lineup, hiddenPositions) {
const pairs = [];
for (let l = 0; l < hiddenPositions.length; l += 1) {
for (let r = l + 1; r < hiddenPositions.length; r += 1) {
const left = hiddenPositions[l];
const right = hiddenPositions[r];
const subject = `${subjectFor(left)} and ${positionLabels[right]}`;
pairs.push(clue(buildFormerClubClue(lineup[left], lineup[right], subject), "history"));
pairs.push(clue(buildLeagueTitleComparisonClue(left, lineup[left], right, lineup[right]), "comparison"));
}
}
return dedupeClues(pairs);
}
function buildHardPairClues(lineup, hiddenPositions) {
const pairs = [];
for (let l = 0; l < hiddenPositions.length; l += 1) {
for (let r = l + 1; r < hiddenPositions.length; r += 1) {
const left = hiddenPositions[l];
const right = hiddenPositions[r];
const subject = `${subjectFor(left)} and ${positionLabels[right]}`;
pairs.push(clue(buildFormerClubClue(lineup[left], lineup[right], subject), "history"));
pairs.push(clue(buildChampionsLeagueComparisonClue(left, lineup[left], right, lineup[right]), "champions"));
pairs.push(clue(buildLeagueTitleComparisonClue(left, lineup[left], right, lineup[right]), "comparison"));
}
}
return dedupeClues(pairs);
}
function dedupeClues(clues) {
return [...new Map(clues.map((item) => [item.text, item])).values()];
}
@@ -297,65 +368,62 @@ function ensureCoverage(clues, hiddenPositions) {
const selected = [];
const covered = new Set();
const usedTypes = new Set();
const positionCounts = Object.fromEntries(hiddenPositions.map((position) => [position, 0]));
const positionCounts = Object.fromEntries(hiddenPositions.map((p) => [p, 0]));
const normalizedClues = clues.map((item) => (typeof item === "string" ? clue(item, "general") : item));
while (covered.size < hiddenPositions.length) {
const nextClue = normalizedClues
.filter((candidate) => !selected.includes(candidate))
.map((candidate) => ({
clue: candidate,
positions: mentionedPositions(candidate.text, hiddenPositions),
}))
.filter((candidate) => candidate.positions.some((position) => !covered.has(position)))
.sort((left, right) => {
const leftNew = left.positions.filter((position) => !covered.has(position)).length;
const rightNew = right.positions.filter((position) => !covered.has(position)).length;
const leftTypeBonus = usedTypes.has(left.clue.type) ? 0 : 1;
const rightTypeBonus = usedTypes.has(right.clue.type) ? 0 : 1;
return rightNew - leftNew || rightTypeBonus - leftTypeBonus || left.positions.length - right.positions.length;
.filter((c) => !selected.includes(c))
.map((c) => ({ clue: c, positions: mentionedPositions(c.text, hiddenPositions) }))
.filter((c) => c.positions.some((p) => !covered.has(p)))
.sort((l, r) => {
const lNew = l.positions.filter((p) => !covered.has(p)).length;
const rNew = r.positions.filter((p) => !covered.has(p)).length;
const lTypeBonus = usedTypes.has(l.clue.type) ? 0 : 1;
const rTypeBonus = usedTypes.has(r.clue.type) ? 0 : 1;
return rNew - lNew || rTypeBonus - lTypeBonus || l.positions.length - r.positions.length;
})[0]?.clue;
if (!nextClue) break;
selected.push(nextClue);
usedTypes.add(nextClue.type);
for (const position of mentionedPositions(nextClue.text, hiddenPositions)) {
covered.add(position);
positionCounts[position] += 1;
for (const p of mentionedPositions(nextClue.text, hiddenPositions)) {
covered.add(p);
positionCounts[p] += 1;
}
}
while (selected.length < normalizedClues.length) {
const nextClue = normalizedClues
.filter((candidate) => !selected.includes(candidate))
.sort((left, right) => {
const leftTypeBonus = usedTypes.has(left.type) ? 0 : 1;
const rightTypeBonus = usedTypes.has(right.type) ? 0 : 1;
const leftBalance = clueBalanceScore(left.text, hiddenPositions, positionCounts);
const rightBalance = clueBalanceScore(right.text, hiddenPositions, positionCounts);
return rightTypeBonus - leftTypeBonus || rightBalance - leftBalance;
.filter((c) => !selected.includes(c))
.sort((l, r) => {
const lTypeBonus = usedTypes.has(l.type) ? 0 : 1;
const rTypeBonus = usedTypes.has(r.type) ? 0 : 1;
const lBalance = clueBalanceScore(l.text, hiddenPositions, positionCounts);
const rBalance = clueBalanceScore(r.text, hiddenPositions, positionCounts);
return rTypeBonus - lTypeBonus || rBalance - lBalance;
})[0];
if (!nextClue) break;
selected.push(nextClue);
usedTypes.add(nextClue.type);
for (const position of mentionedPositions(nextClue.text, hiddenPositions)) {
positionCounts[position] += 1;
for (const p of mentionedPositions(nextClue.text, hiddenPositions)) {
positionCounts[p] += 1;
}
}
return dedupeClues(selected).map((item) => item.text);
}
function mentionedPositions(clue, hiddenPositions) {
const normalized = clue.toLowerCase();
return hiddenPositions.filter((position) => normalized.includes(positionLabels[position]));
function mentionedPositions(clueText, hiddenPositions) {
const normalized = clueText.toLowerCase();
return hiddenPositions.filter((p) => normalized.includes(positionLabels[p]));
}
function clueBalanceScore(clueText, hiddenPositions, positionCounts) {
const positions = mentionedPositions(clueText, hiddenPositions);
if (positions.length === 0) return -99;
return -positions.reduce((sum, position) => sum + positionCounts[position], 0) / positions.length;
return -positions.reduce((sum, p) => sum + positionCounts[p], 0) / positions.length;
}
function limitClues(clues, max) {
@@ -371,18 +439,3 @@ function addDays(dateKey, daysToAdd) {
date.setUTCDate(date.getUTCDate() + daysToAdd);
return date.toISOString().slice(0, 10);
}
function getTodayKeyInTimeZone(date, timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(date);
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
return `${year}-${month}-${day}`;
}
+4 -1
View File
@@ -109,11 +109,14 @@ for (const puzzle of puzzles) {
console.log("Puzzle data OK");
function classifyClue(clue) {
if (clue.includes("teammates")) return "history";
if (clue.includes("teammates") || clue.includes("play for the same club")) return "history";
if (clue.includes("Champions League")) return "champions";
if (clue.includes("league title")) return "titles";
if (clue.includes("plays in") || clue.includes("top five leagues")) return "league";
if (clue.includes("plays for ")) return "club";
if (clue.includes("is from") || clue.includes("comes from")) return "nationality";
if (clue.includes("same club") || clue.includes("different clubs")) return "club";
if (clue.includes("World Cup") || clue.includes("European Championship") || clue.includes("Copa América")) return "trophy";
if (clue.includes("Ballon d'Or") || clue.includes("Golden Boot")) return "award";
return "other";
}