221 lines
8.0 KiB
JavaScript
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}`;
|
|
}
|