2a8d055da3
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>
442 lines
17 KiB
JavaScript
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);
|
|
}
|