Files
hidden11/scripts/generate-puzzles.mjs
alexandrev-tibco 2a8d055da3
Build and push image / build (push) Successful in 1m19s
Fix Close label translation and replace liga with campeonato in clues
- 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>
2026-05-29 07:18:58 +02:00

442 lines
17 KiB
JavaScript

import { writeFileSync } from "node:fs";
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 nationalityRegions = {
Argentina: "South America",
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",
Poland: "Europe",
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));
const totalCombinations = byPosition.reduce((product, group) => product * group.length, 1);
const permutationA = 1103;
const permutationB = 7919;
const puzzles = [];
for (let day = 0; day < DAYS; day += 1) {
const dateKey = addDays(START_DATE_KEY, day);
for (let difficultyIndex = 0; difficultyIndex < DIFFICULTIES.length; difficultyIndex += 1) {
const ordinal = day * DIFFICULTIES.length + difficultyIndex;
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,
clues: buildClues(lineup, difficulty, fixedPosition),
});
}
}
writeFileSync(new URL("../data/puzzles.json", import.meta.url), `${JSON.stringify(puzzles, null, 2)}\n`);
function permuteIndex(value, modulo) {
return (permutationA * value + permutationB) % modulo;
}
function lineupFromIndex(index) {
const [gks, defs, mids, wings, sts] = byPosition;
const divisorGk = defs.length * mids.length * wings.length * sts.length;
const divisorDef = mids.length * wings.length * sts.length;
const divisorMid = wings.length * sts.length;
let remaining = index;
const gkIndex = Math.floor(remaining / divisorGk);
remaining %= divisorGk;
const defIndex = Math.floor(remaining / divisorDef);
remaining %= divisorDef;
const midIndex = Math.floor(remaining / divisorMid);
remaining %= divisorMid;
const wingIndex = Math.floor(remaining / sts.length);
const stIndex = remaining % sts.length;
return {
GK: gks[gkIndex],
DEF: defs[defIndex],
MID: mids[midIndex],
WING: wings[wingIndex],
ST: sts[stIndex],
};
}
function buildClues(lineup, difficulty, fixedPosition) {
const hiddenPositions = POSITIONS.filter((p) => p !== fixedPosition);
if (difficulty === "easy") return buildEasyClues(lineup, hiddenPositions);
if (difficulty === "medium") return buildMediumClues(lineup, hiddenPositions);
return buildHardClues(lineup, hiddenPositions);
}
// 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(dedupeClues([...easyClueLine, ...mediumLines, ...hardLines, ...pairClues]), hiddenPositions),
6,
);
}
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}.`;
}
function buildCurrentLeagueClue(position, player) {
return `${subjectFor(position)} plays in ${player.league}.`;
}
function buildNationalityRegionClue(position, player) {
return `${subjectFor(position)} comes from ${nationalityRegions[player.nationality] ?? "Europe"}.`;
}
function buildLeagueFamilyClue(position, player) {
if (player.league === "Premier League") return `${subjectFor(position)} plays in England.`;
if (player.league === "LaLiga") return `${subjectFor(position)} plays in Spain.`;
if (player.league === "Serie A") return `${subjectFor(position)} plays in Italy.`;
if (player.league === "Bundesliga") return `${subjectFor(position)} plays in Germany.`;
if (player.league === "Ligue 1") return `${subjectFor(position)} plays in France.`;
return `${subjectFor(position)} plays outside the top five leagues.`;
}
// 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) {
if ((player.leagueTitleCountries?.length ?? 0) > 0 && player.leagueTitles > 2) {
return buildTitleCountryClue(player, subject);
}
return buildLeagueTitleCountClue(player, subject);
}
function buildTitleCountryClue(player, subject) {
const country = player.leagueTitleCountries?.[0];
if (country) return `${subject} has won league titles in ${country}.`;
return buildLeagueTitleCountClue(player, subject);
}
function buildLeagueTitleCountClue(player, subject) {
return `${subject} has won ${player.leagueTitles} league title${player.leagueTitles === 1 ? "" : "s"}.`;
}
function buildLeagueTitleBucketClue(player, subject) {
if (player.leagueTitles === 0) return `${subject} has not won a league title.`;
if (player.leagueTitles <= 2) return `${subject} has won at most 2 league titles.`;
if (player.leagueTitles <= 5) return `${subject} has won between 3 and 5 league titles.`;
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 (playerB.leagueTitles > playerA.leagueTitles) {
return `${subjectFor(positionB)} has more league titles than the ${positionLabels[positionA]}.`;
}
return `${subjectA} and ${positionLabels[positionB]} have won the same number of league titles.`;
}
function buildChampionsLeagueComparisonClue(positionA, playerA, positionB, playerB) {
const subjectA = subjectFor(positionA);
const subjectB = `the ${positionLabels[positionB]}`;
if (playerA.championsLeagueTitles > playerB.championsLeagueTitles) {
return `${subjectA} has more Champions League titles than ${subjectB}.`;
}
if (playerB.championsLeagueTitles > playerA.championsLeagueTitles) {
return `${subjectFor(positionB)} has more Champions League titles than the ${positionLabels[positionA]}.`;
}
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()];
}
function ensureCoverage(clues, hiddenPositions) {
const selected = [];
const covered = new Set();
const usedTypes = new Set();
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((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 p of mentionedPositions(nextClue.text, hiddenPositions)) {
covered.add(p);
positionCounts[p] += 1;
}
}
while (selected.length < normalizedClues.length) {
const nextClue = normalizedClues
.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 p of mentionedPositions(nextClue.text, hiddenPositions)) {
positionCounts[p] += 1;
}
}
return dedupeClues(selected).map((item) => item.text);
}
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, p) => sum + positionCounts[p], 0) / positions.length;
}
function limitClues(clues, max) {
return clues.slice(0, max);
}
function clue(text, type) {
return { text, type };
}
function addDays(dateKey, daysToAdd) {
const date = new Date(`${dateKey}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + daysToAdd);
return date.toISOString().slice(0, 10);
}