Files
hidden11/lib/game-engine.ts
T
claude-code dd9497193a
Build and push image / build (push) Successful in 8m18s
Bound the prerendered archive window
Prerendering every past answer page in six languages meant 700 static pages,
and the CI runner blew past Next's 60s per-page export timeout on
/puzzle/236, failing the build. The set also grew by six pages a day, so
every build would have been slower than the last.

generateStaticParams now covers the most recent 30 days per locale (202
pages, ~35s locally). Older days keep working exactly as before: they are
still linked, still in the sitemap and still indexable, but render on first
request and are then cached by ISR — measured at 180ms cold.

Also raises staticPageGenerationTimeout to 180s for headroom, since the
runner shares a host with Gitea and Postgres.

Refs #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
2026-08-26 13:04:49 +00:00

149 lines
5.6 KiB
TypeScript

import playersData from "@/data/players.json";
import puzzlesData from "@/data/puzzles.json";
import { getDateKeyInTimeZone } from "./time";
import type { Difficulty, Feedback, Guess, Player, Position, Puzzle } from "./types";
export const POSITIONS: Position[] = ["GK", "DEF", "MID", "WING", "ST"];
export const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
export const MAX_ATTEMPTS = 6;
export const players = playersData as Player[];
export const puzzles = puzzlesData as Puzzle[];
export function getTodayKey(date = new Date()): string {
return getDateKeyInTimeZone(date);
}
export function getPuzzleNumber(dateKey: string): number {
const epoch = Date.UTC(2026, 0, 1);
const current = Date.parse(`${dateKey}T00:00:00.000Z`);
return Math.max(1, Math.floor((current - epoch) / 86400000) + 1);
}
export function getDateKeyForPuzzleNumber(puzzleNumber: number): string {
const epoch = Date.UTC(2026, 0, 1);
return new Date(epoch + (puzzleNumber - 1) * 86400000).toISOString().slice(0, 10);
}
// Past days that have a dedicated (non-rotated) puzzle set — the only ones the
// public archive exposes, so answer pages never duplicate or leak content.
export function getArchiveDateKeys(todayKey: string): string[] {
const complete = new Map<string, number>();
puzzles.forEach((puzzle) => {
complete.set(puzzle.date, (complete.get(puzzle.date) ?? 0) + 1);
});
return Array.from(complete.entries())
.filter(([date, count]) => count === DIFFICULTIES.length && date < todayKey)
.map(([date]) => date)
.sort((left, right) => right.localeCompare(left));
}
// Resolves a public answer-page target from a raw URL segment. Returns null for
// malformed numbers, for days without a dedicated puzzle set, and for today or
// any future day — solutions are never rendered before they have been played.
export function getPastPuzzleSet(rawNumber: string): { number: number; dateKey: string; sets: Puzzle[] } | null {
const number = Number.parseInt(rawNumber, 10);
if (!Number.isFinite(number) || number < 1 || String(number) !== rawNumber) return null;
const dateKey = getDateKeyForPuzzleNumber(number);
if (dateKey >= getTodayKey()) return null;
const sets = puzzles.filter((puzzle) => puzzle.date === dateKey);
if (sets.length !== DIFFICULTIES.length) return null;
return { number, dateKey, sets };
}
// How many recent days get prerendered at build time. The archive grows by one
// day forever, so an unbounded generateStaticParams would make every CI build
// slower than the last — 700 pages already blew past Next's per-page export
// timeout. Older answer pages stay fully public and indexable: they are just
// rendered on first request and then cached by ISR.
export const PRERENDERED_ARCHIVE_DAYS = 30;
export function getRecentArchiveDateKeys(todayKey: string): string[] {
return getArchiveDateKeys(todayKey).slice(0, PRERENDERED_ARCHIVE_DAYS);
}
export function getDailyPuzzles(dateKey: string): Puzzle[] {
const dated = puzzles.filter((puzzle) => puzzle.date === dateKey);
const source = dated.length === DIFFICULTIES.length ? dated : getRotatingPuzzleSet(dateKey);
return DIFFICULTIES.map((difficulty) => {
const puzzle = source.find((item) => item.difficulty === difficulty);
if (!puzzle) {
throw new Error(`Missing ${difficulty} puzzle`);
}
return puzzle;
});
}
function getRotatingPuzzleSet(dateKey: string): Puzzle[] {
const grouped = Array.from(
puzzles.reduce((map, puzzle) => {
const bucket = map.get(puzzle.date) ?? [];
bucket.push(puzzle);
map.set(puzzle.date, bucket);
return map;
}, new Map<string, Puzzle[]>()),
)
.map(([, items]) => items)
.filter((items) => items.length === DIFFICULTIES.length)
.sort((left, right) => left[0].date.localeCompare(right[0].date));
if (grouped.length === 0) {
throw new Error("No complete puzzle sets available");
}
const index = (getPuzzleNumber(dateKey) - 1) % grouped.length;
return grouped[index];
}
export function findPlayer(id: string | null): Player | undefined {
if (!id) return undefined;
return players.find((player) => player.id === id);
}
export function validateGuess(puzzle: Puzzle, guess: Record<Position, string | null>): Guess {
return {
slots: POSITIONS.map((position) => ({
position,
playerId: guess[position],
feedback: scoreSlot(puzzle.solution[position], guess[position]),
})),
};
}
export function isSolved(guess: Guess): boolean {
return guess.slots.every((slot) => slot.feedback === "correct");
}
export function isAttemptComplete(guess: Record<Position, string | null>): boolean {
return POSITIONS.every((position) => Boolean(guess[position]));
}
export function getFixedGuess(puzzle: Puzzle): Record<Position, string | null> {
return POSITIONS.reduce<Record<Position, string | null>>((acc, position) => {
acc[position] = position === puzzle.fixedPosition ? puzzle.solution[position] : null;
return acc;
}, {} as Record<Position, string | null>);
}
function scoreSlot(solutionId: string, guessId: string | null): Feedback {
if (!guessId) return "empty";
if (guessId === solutionId) return "correct";
const solution = findPlayer(solutionId);
const guess = findPlayer(guessId);
if (!solution || !guess) return "wrong";
const hasClubRelation = solution.club === guess.club;
const hasLeagueMatch = solution.league === guess.league;
const hasNationalityMatch = solution.nationality === guess.nationality;
const hasPositionMatch = solution.position === guess.position;
return hasClubRelation || hasLeagueMatch || hasNationalityMatch || hasPositionMatch ? "partial" : "wrong";
}