155 lines
4.9 KiB
TypeScript
155 lines
4.9 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[];
|
|
};
|
|
|
|
type SharePayload = {
|
|
dateKey: string;
|
|
puzzleNumber: number;
|
|
difficulties: Record<Difficulty, ShareDifficultySummary>;
|
|
};
|
|
|
|
export function buildShareText(dateKey: string, progress: DailyProgress, t: Dictionary): string {
|
|
const homeUrl = "https://hidden11.app";
|
|
const rows = DIFFICULTIES.map((difficulty) => {
|
|
const item = progress[difficulty];
|
|
const label = t.difficulty[difficulty];
|
|
if (item.locked) return `${label.padEnd(8)} 🔒`;
|
|
|
|
const result = item.solved ? `${item.attempts.length}/${MAX_ATTEMPTS}` : `X/${MAX_ATTEMPTS}`;
|
|
const time = formatElapsed(item.elapsedSeconds);
|
|
const grids = item.attempts.length
|
|
? item.attempts
|
|
.map((attempt) =>
|
|
POSITIONS.map((position) => {
|
|
const slot = attempt.slots.find((candidate) => candidate.position === position);
|
|
return emoji[slot?.feedback ?? "empty"];
|
|
}).join(""),
|
|
)
|
|
.join("\n")
|
|
: "⬛⬛⬛⬛⬛";
|
|
|
|
return `${label.padEnd(6)} ${result} · ${time}\n${grids}`;
|
|
});
|
|
|
|
return [
|
|
"┏━ hidden11.app ━┓",
|
|
`⚽ hidden11 #${getPuzzleNumber(dateKey)}`,
|
|
"",
|
|
...rows,
|
|
"",
|
|
`Play: ${homeUrl}`,
|
|
].join("\n");
|
|
}
|
|
|
|
export function buildGlobalStatsShareText(stats: GlobalStats, t: Dictionary): string {
|
|
const homeUrl = "https://hidden11.app";
|
|
const bestTime = stats.bestSeconds === null ? "0:00" : formatElapsed(stats.bestSeconds);
|
|
|
|
return [
|
|
"┏━ hidden11.app ━┓",
|
|
"⚽ hidden11 global 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}`,
|
|
"",
|
|
`Play: ${homeUrl}`,
|
|
].join("\n");
|
|
}
|
|
|
|
export function buildChallengeShareText(dateKey: string, progress: DailyProgress, t: Dictionary): string {
|
|
return [t.challengeShareLead, "", buildShareText(dateKey, progress, t)].join("\n");
|
|
}
|
|
|
|
export function buildShareUrl(_dateKey: string, _progress: DailyProgress, origin = "https://hidden11.app"): string {
|
|
return origin.replace(/\/$/, "");
|
|
}
|
|
|
|
export function buildShareResultUrl(dateKey: string, progress: DailyProgress, origin = "https://hidden11.app"): string {
|
|
const payload: SharePayload = {
|
|
dateKey,
|
|
puzzleNumber: getPuzzleNumber(dateKey),
|
|
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;
|
|
}
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|