0de85cbc51
Build and push image / build (push) Failing after 15m20s
Archive and answer pages now exist in all six languages and are prerendered instead of rendered per request, and each answer page carries a sentence of real context per player (club, league, nationality, former clubs, honours) plus Article and BreadcrumbList JSON-LD. The sitemap grows from 121 to 691 URLs, every one with hreflang alternates. Adds an optional display name to shared results, so a challenge link reads "<name> challenges you" in the page, the OG image and the head-to-head verdict; the name is sanitised both when written and when parsed back. Adds an opt-in daily reminder (Web Push without payload, so no content encryption) and a PWA install prompt shown once the day is finished, plus first-party event ingestion at /api/events that mirrors GA4 so the funnel survives ad blockers. Wires the missing N8N_PLAYS_WEBHOOK_URL into the deployment along with the new events, push and search-verification secrets, and ships the n8n workflows, the Postgres schema with funnel/retention views, and an IndexNow submitter. Refs #2 #3 #5 #6 #7 #8 #9 #10 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
237 lines
8.2 KiB
TypeScript
237 lines
8.2 KiB
TypeScript
import { DIFFICULTIES, getPuzzleNumber, MAX_ATTEMPTS, POSITIONS } from "./game-engine";
|
|
import type { Dictionary } from "./i18n";
|
|
import { formatElapsed } from "./time";
|
|
import type { DailyProgress, Difficulty, GlobalStats } from "./types";
|
|
|
|
const emoji = {
|
|
correct: "🟩",
|
|
partial: "🟨",
|
|
wrong: "⬛",
|
|
empty: "⬛",
|
|
};
|
|
|
|
type ShareDifficultySummary = {
|
|
attempts: number;
|
|
locked: boolean;
|
|
solved: boolean;
|
|
elapsedSeconds: number;
|
|
rows: string[];
|
|
};
|
|
|
|
export type SharePayload = {
|
|
dateKey: string;
|
|
puzzleNumber: number;
|
|
difficulties: Record<Difficulty, ShareDifficultySummary>;
|
|
// Optional display name of whoever shared the result. Free text typed by the
|
|
// player, so every consumer sanitises it before rendering.
|
|
name?: string;
|
|
};
|
|
|
|
function attemptRow(progressAttempt: { slots: { position: string; feedback: keyof typeof emoji }[] }): string {
|
|
return POSITIONS.map((position) => {
|
|
const slot = progressAttempt.slots.find((candidate) => candidate.position === position);
|
|
return emoji[slot?.feedback ?? "empty"];
|
|
}).join("");
|
|
}
|
|
|
|
// Share text stays compact on purpose: one line per difficulty with the final
|
|
// attempt's grid. The destination URL travels separately on every channel.
|
|
export function buildShareText(dateKey: string, progress: DailyProgress, t: Dictionary): string {
|
|
const solvedCount = DIFFICULTIES.filter((difficulty) => progress[difficulty].solved).length;
|
|
const totalSeconds = DIFFICULTIES.reduce((sum, difficulty) => sum + progress[difficulty].elapsedSeconds, 0);
|
|
const rows = DIFFICULTIES.map((difficulty) => {
|
|
const item = progress[difficulty];
|
|
const label = t.difficulty[difficulty];
|
|
if (item.locked) return `${label} 🔒`;
|
|
|
|
const result = item.solved ? `${item.attempts.length}/${MAX_ATTEMPTS}` : `X/${MAX_ATTEMPTS}`;
|
|
const lastAttempt = item.attempts[item.attempts.length - 1];
|
|
const grid = lastAttempt ? attemptRow(lastAttempt) : "⬛⬛⬛⬛⬛";
|
|
return `${label} ${result} ${grid}`;
|
|
});
|
|
|
|
return [
|
|
`⚽ hidden11 #${getPuzzleNumber(dateKey)} · ${solvedCount}/${DIFFICULTIES.length} · ${formatElapsed(totalSeconds)}`,
|
|
...rows,
|
|
].join("\n");
|
|
}
|
|
|
|
// Single-difficulty share used right after an early solve, before the day is done.
|
|
export function buildDifficultyShareText(
|
|
dateKey: string,
|
|
difficulty: Difficulty,
|
|
progress: DailyProgress,
|
|
t: Dictionary,
|
|
): string {
|
|
const item = progress[difficulty];
|
|
const result = item.solved ? `${item.attempts.length}/${MAX_ATTEMPTS}` : `X/${MAX_ATTEMPTS}`;
|
|
const grids = item.attempts.map((attempt) => attemptRow(attempt));
|
|
|
|
return [
|
|
`⚽ hidden11 #${getPuzzleNumber(dateKey)} — ${t.difficulty[difficulty]} ${result} · ${formatElapsed(item.elapsedSeconds)}`,
|
|
...grids,
|
|
].join("\n");
|
|
}
|
|
|
|
export function buildGlobalStatsShareText(stats: GlobalStats, t: Dictionary): string {
|
|
const bestTime = stats.bestSeconds === null ? "0:00" : formatElapsed(stats.bestSeconds);
|
|
|
|
return [
|
|
"⚽ hidden11 stats",
|
|
`${t.played}: ${stats.gamesPlayed} · ${t.streak}: ${stats.currentStreak}🔥 · ${t.maxStreak}: ${stats.maxStreak}`,
|
|
`${t.perfectDays}: ${stats.perfectDays} · ${t.averageTime}: ${formatElapsed(stats.averageSeconds)} · ${t.bestTime}: ${bestTime}`,
|
|
].join("\n");
|
|
}
|
|
|
|
export function buildChallengeShareText(
|
|
dateKey: string,
|
|
progress: DailyProgress,
|
|
t: Dictionary,
|
|
name?: string,
|
|
): string {
|
|
const cleanName = name ? sanitizeShareName(name) : "";
|
|
const lead = cleanName ? t.challengeShareLeadNamed(cleanName) : t.challengeShareLead;
|
|
return [lead, buildShareText(dateKey, progress, t)].join("\n");
|
|
}
|
|
|
|
export const MAX_SHARE_NAME_LENGTH = 20;
|
|
|
|
// Strips anything that could turn a name into markup or a second line of text,
|
|
// and caps the length so the share code stays short.
|
|
export function sanitizeShareName(raw: string): string {
|
|
return raw
|
|
.replace(/[<>&"'`\r\n\t]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, MAX_SHARE_NAME_LENGTH);
|
|
}
|
|
|
|
export function buildShareResultUrl(
|
|
dateKey: string,
|
|
progress: DailyProgress,
|
|
origin = "https://hidden11.app",
|
|
name?: string,
|
|
): string {
|
|
const cleanName = name ? sanitizeShareName(name) : "";
|
|
const payload: SharePayload = {
|
|
dateKey,
|
|
puzzleNumber: getPuzzleNumber(dateKey),
|
|
...(cleanName ? { name: cleanName } : {}),
|
|
difficulties: DIFFICULTIES.reduce<Record<Difficulty, ShareDifficultySummary>>((acc, difficulty) => {
|
|
const item = progress[difficulty];
|
|
acc[difficulty] = {
|
|
attempts: item.attempts.length,
|
|
locked: item.locked,
|
|
solved: item.solved,
|
|
elapsedSeconds: item.elapsedSeconds,
|
|
rows: item.attempts.map((attempt) =>
|
|
POSITIONS.map((position) => {
|
|
const slot = attempt.slots.find((candidate) => candidate.position === position);
|
|
return emoji[slot?.feedback ?? "empty"];
|
|
}).join(""),
|
|
),
|
|
};
|
|
return acc;
|
|
}, {} as Record<Difficulty, ShareDifficultySummary>),
|
|
};
|
|
|
|
return `${origin.replace(/\/$/, "")}/r/${encodeSharePayload(payload)}`;
|
|
}
|
|
|
|
export function parseSharePayload(shareCode: string): SharePayload | null {
|
|
try {
|
|
const decoded = decodeBase64Url(shareCode);
|
|
const payload = JSON.parse(decoded) as SharePayload;
|
|
if (!payload?.dateKey || !payload?.puzzleNumber || !payload?.difficulties) {
|
|
return null;
|
|
}
|
|
// The share code is attacker-controlled: re-sanitise the name on the way in
|
|
// rather than trusting whatever was encoded.
|
|
const name = typeof payload.name === "string" ? sanitizeShareName(payload.name) : "";
|
|
return name ? { ...payload, name } : { ...payload, name: undefined };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type ChallengeOutcome = "win" | "loss" | "tie";
|
|
|
|
type ChallengeScore = {
|
|
solved: number;
|
|
attempts: number;
|
|
seconds: number;
|
|
};
|
|
|
|
export function payloadScore(payload: SharePayload): ChallengeScore {
|
|
return DIFFICULTIES.reduce<ChallengeScore>(
|
|
(acc, difficulty) => {
|
|
const item = payload.difficulties[difficulty];
|
|
if (!item || item.locked) return acc;
|
|
return {
|
|
solved: acc.solved + (item.solved ? 1 : 0),
|
|
attempts: acc.attempts + item.attempts,
|
|
seconds: acc.seconds + item.elapsedSeconds,
|
|
};
|
|
},
|
|
{ solved: 0, attempts: 0, seconds: 0 },
|
|
);
|
|
}
|
|
|
|
export function progressScore(progress: DailyProgress): ChallengeScore {
|
|
return DIFFICULTIES.reduce<ChallengeScore>(
|
|
(acc, difficulty) => {
|
|
const item = progress[difficulty];
|
|
if (item.locked) return acc;
|
|
return {
|
|
solved: acc.solved + (item.solved ? 1 : 0),
|
|
attempts: acc.attempts + item.attempts.length,
|
|
seconds: acc.seconds + item.elapsedSeconds,
|
|
};
|
|
},
|
|
{ solved: 0, attempts: 0, seconds: 0 },
|
|
);
|
|
}
|
|
|
|
export function formatScoreLine(score: ChallengeScore): string {
|
|
return `${score.solved}/${DIFFICULTIES.length} · ${formatElapsed(score.seconds)}`;
|
|
}
|
|
|
|
// Rank by solved difficulties, then fewer attempts, then faster total time.
|
|
export function compareChallenge(mine: ChallengeScore, rival: ChallengeScore): ChallengeOutcome {
|
|
if (mine.solved !== rival.solved) return mine.solved > rival.solved ? "win" : "loss";
|
|
if (mine.attempts !== rival.attempts) return mine.attempts < rival.attempts ? "win" : "loss";
|
|
if (mine.seconds !== rival.seconds) return mine.seconds < rival.seconds ? "win" : "loss";
|
|
return "tie";
|
|
}
|
|
|
|
function encodeSharePayload(payload: SharePayload): string {
|
|
return encodeBase64Url(JSON.stringify(payload));
|
|
}
|
|
|
|
function encodeBase64Url(value: string): string {
|
|
if (typeof window === "undefined") {
|
|
return Buffer.from(value, "utf8").toString("base64url");
|
|
}
|
|
|
|
const bytes = new TextEncoder().encode(value);
|
|
let binary = "";
|
|
|
|
bytes.forEach((byte) => {
|
|
binary += String.fromCharCode(byte);
|
|
});
|
|
|
|
return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
}
|
|
|
|
function decodeBase64Url(value: string): string {
|
|
if (typeof window === "undefined") {
|
|
return Buffer.from(value, "base64url").toString("utf8");
|
|
}
|
|
|
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
|
|
const binary = window.atob(`${normalized}${padding}`);
|
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
return new TextDecoder().decode(bytes);
|
|
}
|