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; // 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>((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), }; 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( (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( (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); }