Files
hidden11/scripts/generate-puzzles.mjs
T
alexandrev-tibco f784c9c722
Build and push image / build (push) Successful in 1m19s
Reveal failed puzzles and improve clue coverage
2026-05-07 11:53:14 +02:00

221 lines
8.0 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 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((position) => position !== fixedPosition);
if (difficulty === "easy") {
return hiddenPositions.flatMap((position) => [
buildNationalityClue(position, lineup[position]),
buildCurrentLeagueClue(position, lineup[position]),
]);
}
if (difficulty === "medium") {
return dedupeClues([
...hiddenPositions.map((position) => buildTitleCountryClue(lineup[position], subjectFor(position))),
...buildHiddenPairClues(lineup, hiddenPositions).slice(0, 2),
]);
}
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 subjectFor(position) {
return `The ${positionLabels[position]}`;
}
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) {
const country = player.leagueTitleCountries?.[0];
if (country) {
return `${subject} has won league titles in ${country}.`;
}
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);
}
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(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) {
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 buildLeagueTitleCountClue(player, subject) {
return `${subject} has won ${player.leagueTitles} league title${player.leagueTitles === 1 ? "" : "s"}.`;
}
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 dedupeClues(clues) {
return [...new Set(clues)];
}
function addDays(dateKey, daysToAdd) {
const date = new Date(`${dateKey}T00:00:00.000Z`);
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}`;
}