Add profile photo uploads and custom difficulty support

This commit is contained in:
2026-10-13 20:50:54 +05:00
parent 8c3de302e7
commit de812453ec
37 changed files with 1139 additions and 74 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
.git
**/.git
node_modules
**/node_modules
.next
**/.next
.env*
**/.env*
.cache
**/.cache
.data
artifacts
coverage
playwright-report
test-results
*.log
*.hprof
desktop/dist
public/generated

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM dependencies AS builder
COPY . .
# Next.js embeds NEXT_PUBLIC_* values into browser bundles; the server secret is runtime-only.
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
NODE_OPTIONS=--max-old-space-size=640 \
NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY}
RUN if [ -z "$NEXT_PUBLIC_SUPABASE_URL" ] || [ -z "$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY" ]; then \
echo "Pass NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY as Docker build arguments." >&2; \
exit 1; \
fi && npm run build
FROM node:22-bookworm-slim AS production-dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
NODE_OPTIONS=--max-old-space-size=256 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
MINEGEO_HOST=0.0.0.0 \
MINEGEO_WORKER_HEAP_MB=512 \
UV_THREADPOOL_SIZE=2
COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder --chown=node:node /app/public ./public
COPY --chown=node:node package.json tsconfig.json next.config.ts ./
COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs ./scripts/
COPY --chown=node:node worker ./worker
COPY --chown=node:node shared ./shared
COPY --chown=node:node lib ./lib
COPY --chown=node:node renderer ./renderer
RUN mkdir -p /app/.cache && chown node:node /app/.cache
USER node
EXPOSE 3000
CMD ["node", "scripts/supervise.mjs", "production"]

View File

@@ -177,6 +177,29 @@
box-shadow: 0 12px 30px #0002;
min-width: 0;
}
.account-avatar-control {
display: flex;
align-items: center;
gap: 18px;
margin: 24px 0;
}
.account-avatar-preview {
width: 84px;
height: 84px;
object-fit: cover;
box-shadow: 3px 3px #03070b;
}
.account-avatar-input {
flex: 1;
min-width: 0;
}
.account-avatar-input .field {
margin: 0 0 12px;
}
.account-avatar-input .field input {
min-height: 40px;
padding: 8px;
}
.settings-heading {
display: flex;
align-items: flex-start;

View File

@@ -18,7 +18,7 @@ export default async function DashboardPage() {
? required(
await admin()
.from("game_sessions")
.select("id,difficulty,created_at,current_round")
.select("id,difficulty,difficulty_settings,created_at,current_round")
.eq("owner_id", account.id)
.gte("created_at", historyCutoff)
.order("created_at", { ascending: false })
@@ -52,7 +52,17 @@ export default async function DashboardPage() {
<div className="draft-row" key={s.id}>
<span>
{formatDate(s.created_at)} ·{" "}
{t(s.difficulty[0].toUpperCase() + s.difficulty.slice(1))}{" "}
{s.difficulty_settings &&
typeof s.difficulty_settings === "object" &&
!Array.isArray(s.difficulty_settings) &&
typeof s.difficulty_settings.name === "string"
? ["easy", "normal", "hard"].includes(s.difficulty)
? t(s.difficulty_settings.name)
: s.difficulty_settings.name
: t(
s.difficulty[0].toUpperCase() +
s.difficulty.slice(1),
)}{" "}
· {s.current_round}
{t("/5 rounds")}
</span>

View File

@@ -801,7 +801,7 @@ nav a:hover,
}
.difficulty-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin: 25px 0;
}
@@ -829,6 +829,51 @@ nav a:hover,
font-size: 10px;
margin-top: 5px;
}
.difficulty-library {
margin: -12px 0 24px;
}
.difficulty-builder {
display: grid;
gap: 10px;
max-width: 430px;
margin-top: 12px;
padding: 18px;
border: 1px solid var(--line);
background: #0d160f;
}
.difficulty-builder label {
color: var(--muted);
font-size: 12px;
}
.difficulty-builder > input,
.difficulty-builder-time input {
min-height: 42px;
padding: 9px 11px;
border: 1px solid #34452e;
background: var(--panel);
color: var(--text);
font: inherit;
}
.difficulty-builder-time {
display: flex;
align-items: center;
gap: 9px;
}
.difficulty-builder-time input {
width: 120px;
}
.difficulty-builder-time span {
color: var(--muted);
font-size: 12px;
}
.difficulty-builder .difficulty-builder-zoom {
display: flex;
align-items: center;
gap: 9px;
}
.difficulty-builder-zoom input {
accent-color: var(--green);
}
.prose {
max-width: 850px;
}

View File

@@ -238,6 +238,8 @@ input:focus-visible {
width: 36px;
height: 36px;
flex-shrink: 0;
display: block;
object-fit: cover;
background: #314034;
box-shadow: 2px 2px #03070b;
}

View File

@@ -83,6 +83,10 @@
gap: 8px;
flex-wrap: wrap;
}
.multiplayer-avatar {
width: 28px;
height: 28px;
}
.multiplayer-table-wrap {
overflow-x: auto;
margin: 28px 0;
@@ -99,6 +103,13 @@
.multiplayer-final .multiplayer-you {
background: #8dce6614;
}
.multiplayer-final-avatar {
width: 24px;
height: 24px;
display: inline-block;
vertical-align: middle;
margin-right: 6px;
}
.multiplayer-scoreboard {
position: absolute;
z-index: 20;
@@ -139,6 +150,10 @@
flex: 1;
min-width: 0;
}
.multiplayer-scoreboard .multiplayer-avatar {
width: 26px;
height: 26px;
}
.multiplayer-scoreboard li small {
display: block;
font-size: 11px;

View File

@@ -82,8 +82,10 @@ export default async function Settings({
</section>
{active && (
<AccountSettings
accountId={account.id}
email={account.email ?? ""}
displayName={profile.display_name}
avatarPath={profile.avatar_path}
appearance={profile}
passwordUpdated={query.password === "updated"}
/>

View File

@@ -4,22 +4,31 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { createBrowserClient } from "@supabase/ssr";
import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm";
import { displayNameSchema } from "@/shared/account";
import {
defaultGlowColor,
profileAvatarBucket,
profileAvatarMaxBytes,
profileAvatarMimeTypes,
type ProfileAppearance,
} from "@/shared/profile-appearance";
import { Username } from "./Username";
import { PlayerAvatar } from "./PixelArt";
export function AccountSettings({
accountId,
email,
displayName,
avatarPath,
appearance,
passwordUpdated = false,
}: {
accountId: string;
email: string;
displayName: string;
avatarPath: string | null;
appearance: ProfileAppearance;
passwordUpdated?: boolean;
}) {
@@ -36,6 +45,80 @@ export function AccountSettings({
const [colorBusy, setColorBusy] = useState(false),
[colorError, setColorError] = useState(""),
[colorSaved, setColorSaved] = useState(false);
const [photoPath, setPhotoPath] = useState(avatarPath),
[photoBusy, setPhotoBusy] = useState(false),
[photoError, setPhotoError] = useState(""),
[photoSaved, setPhotoSaved] = useState(false);
async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) {
const input = event.currentTarget;
const file = input.files?.[0];
input.value = "";
setPhotoError("");
setPhotoSaved(false);
if (!file) return;
if (file.size > profileAvatarMaxBytes) {
setPhotoError("Images must be 5 MB or smaller.");
return;
}
if (
!profileAvatarMimeTypes.includes(
file.type as (typeof profileAvatarMimeTypes)[number],
)
) {
setPhotoError("Choose a JPG, PNG or WebP image.");
return;
}
const extension =
file.type === "image/jpeg"
? "jpg"
: file.type === "image/png"
? "png"
: "webp";
const path = `${accountId}/${crypto.randomUUID()}.${extension}`;
const client = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
setPhotoBusy(true);
let uploaded = false;
try {
const { error: uploadError } = await client.storage
.from(profileAvatarBucket)
.upload(path, file, {
cacheControl: "31536000",
contentType: file.type,
upsert: false,
});
if (uploadError)
throw new Error("Could not upload your profile photo. Please try again.");
uploaded = true;
await api("profile", { avatarPath: path }, "PATCH");
setPhotoPath(path);
setPhotoSaved(true);
router.refresh();
} catch (error) {
if (uploaded)
await client.storage.from(profileAvatarBucket).remove([path]);
setPhotoError((error as Error).message);
} finally {
setPhotoBusy(false);
}
}
async function removeAvatar() {
setPhotoBusy(true);
setPhotoError("");
setPhotoSaved(false);
try {
await api("profile", { avatarPath: null }, "PATCH");
setPhotoPath(null);
setPhotoSaved(true);
router.refresh();
} catch (error) {
setPhotoError((error as Error).message);
} finally {
setPhotoBusy(false);
}
}
async function saveColor(event: React.FormEvent) {
event.preventDefault();
setColorBusy(true);
@@ -104,6 +187,45 @@ export function AccountSettings({
</div>
</div>
<form onSubmit={save}>
<div className="account-avatar-control">
<PlayerAvatar
id={accountId}
avatarPath={photoPath}
className="account-avatar-preview"
/>
<div className="account-avatar-input">
<label className="field">
{t("Profile photo")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={uploadAvatar}
disabled={photoBusy}
/>
<small>{t("JPG, PNG or WebP · Maximum size: 5 MB.")}</small>
</label>
{photoPath && (
<button
type="button"
className="button"
onClick={removeAvatar}
disabled={photoBusy}
>
{t("Remove photo")}
</button>
)}
</div>
</div>
{photoError && (
<p role="alert" className="error-message">
{t(photoError)}
</p>
)}
{photoSaved && (
<p role="status" className="success-message">
{t("Profile photo saved.")}
</p>
)}
<label className="field">
{t("Display name")}
<input

View File

@@ -15,7 +15,7 @@ import { Brand } from "./Brand";
import { Panorama } from "./Panorama";
import { GuessMap, type Pin } from "./GuessMap";
import { api } from "./StartGame";
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
import type { DifficultyPreset, Bounds } from "@/shared/protocol";
import { roundClock } from "@/shared/round-clock";
import { ReportContent } from "./ReportContent";
export type RoundResult = {
@@ -28,7 +28,8 @@ export type RoundResult = {
export type SessionView = {
id: string;
name: string;
difficulty: Difficulty;
difficulty: string;
difficultySettings: DifficultyPreset;
round: number;
bounds: Bounds;
map: string;
@@ -74,7 +75,7 @@ export function Game({ id }: { id: string }) {
s.rounds[s.round].deadline!,
performance.now(),
)
: difficulties[s.difficulty].seconds,
: s.difficultySettings.seconds,
);
} catch (e) {
if (version === loadVersion.current) setError((e as Error).message);
@@ -196,7 +197,7 @@ export function Game({ id }: { id: string }) {
<Panorama
key={session.round}
urls={current.faces}
zoom={difficulties[session.difficulty].zoom}
zoom={session.difficultySettings.zoom}
onReady={ready}
onError={onError}
/>
@@ -289,7 +290,9 @@ export function Game({ id }: { id: string }) {
</span>
<span>
<MousePointer2 size={12} />{" "}
{session?.difficulty === "hard" ? t("NO ZOOM") : t("SCROLL TO ZOOM")}
{session && !session.difficultySettings.zoom
? t("NO ZOOM")
: t("SCROLL TO ZOOM")}
</span>
</div>
{(!session || !loaded.panorama || !loaded.map) && !error && (

View File

@@ -104,7 +104,10 @@ export function LeaderboardTable({
</td>
<th scope="row">
<div className="leaderboard-player">
<PlayerAvatar id={entry.player_id} />
<PlayerAvatar
id={entry.player_id}
avatarPath={entry.avatar_path}
/>
<span className="player-name">
<Username name={entry.display_name} appearance={entry} />
{entry.player_id === playerId && (
@@ -152,7 +155,10 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
place: String(index + 1).padStart(2, "0"),
})}
</span>
<PlayerAvatar id={entry.player_id} />
<PlayerAvatar
id={entry.player_id}
avatarPath={entry.avatar_path}
/>
<h2>
<Username name={entry.display_name} appearance={entry} />
</h2>

View File

@@ -1,5 +1,6 @@
"use client";
import { Username } from "./Username";
import { PlayerAvatar } from "./PixelArt";
import { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -15,7 +16,6 @@ import { GuessMap, type Pin } from "./GuessMap";
import { ReportContent } from "./ReportContent";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { difficulties } from "@/shared/protocol";
import type { RoomView, RoomMedia } from "@/shared/multiplayer";
export function MultiplayerGame({
@@ -101,7 +101,7 @@ export function MultiplayerGame({
{media && (
<Panorama
urls={media.faces}
zoom={difficulties[room.difficulty].zoom}
zoom={room.difficultySettings.zoom}
onReady={panoramaReady}
onError={mediaError}
/>
@@ -139,6 +139,11 @@ export function MultiplayerGame({
<ul>
{room.players.map((player) => (
<li key={player.id}>
<PlayerAvatar
id={player.id}
avatarPath={player.appearance?.avatar_path}
className="multiplayer-avatar"
/>
<span>
<Username name={player.name} appearance={player.appearance} />
{player.id === room.you && <small> {t("(you)")}</small>}

View File

@@ -12,6 +12,8 @@ import { api } from "@/lib/client-api";
import { roundClock } from "@/shared/round-clock";
import type { RoomView } from "@/shared/multiplayer";
import { Username } from "./Username";
import { PlayerAvatar } from "./PixelArt";
import { difficulties } from "@/shared/protocol";
export function MultiplayerRoom({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
@@ -181,6 +183,18 @@ export function MultiplayerRoom({ id }: { id: string }) {
<span className="eyebrow">
<Users size={16} /> {t("Multiplayer")} · {room.name}
</span>
<p className="muted">
{t("Difficulty")}: <strong>
{room.difficultySettings.id in difficulties
? t(room.difficultySettings.name)
: room.difficultySettings.name}
</strong>
{" · "}
{room.difficultySettings.seconds} {t("s per round")} ·{" "}
{room.difficultySettings.zoom
? t("Zoom allowed")
: t("No zoom")}
</p>
<h1>
{finished
? t("Final standings")
@@ -317,6 +331,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
{room.players.map((player) => (
<li key={player.id}>
<span>
<PlayerAvatar
id={player.id}
avatarPath={player.appearance?.avatar_path}
className="multiplayer-avatar"
/>
{player.id === room.hostId && (
<Crown size={17} aria-label={t("Room owner")} />
)}{" "}
@@ -419,6 +438,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
>
<td>{player.rank}</td>
<td>
<PlayerAvatar
id={player.id}
avatarPath={player.appearance?.avatar_path}
className="multiplayer-final-avatar"
/>
<Username
name={player.name}
appearance={player.appearance}

View File

@@ -1,3 +1,5 @@
import { profileAvatarUrl } from "@/shared/profile-appearance";
export function PixelTrophy({ className = "" }: { className?: string }) {
return (
<svg
@@ -22,7 +24,27 @@ export function PixelTrophy({ className = "" }: { className?: string }) {
}
// Generated markers, not player skins. No third-party avatar requests.
export function PlayerAvatar({ id }: { id: string }) {
export function PlayerAvatar({
id,
avatarPath,
className = "",
}: {
id: string;
avatarPath?: string | null;
className?: string;
}) {
const avatarUrl = profileAvatarUrl(avatarPath);
if (avatarUrl)
return (
<img
className={`player-avatar ${className}`.trim()}
src={avatarUrl}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
/>
);
const seed = [...id].reduce(
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
0,
@@ -32,7 +54,7 @@ export function PlayerAvatar({ id }: { id: string }) {
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
return (
<svg
className="player-avatar"
className={`player-avatar ${className}`.trim()}
viewBox="0 0 8 8"
aria-hidden="true"
shapeRendering="crispEdges"

View File

@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { api } from "./StartGame";
import { GuessMap } from "./GuessMap";
import type { SessionView } from "./Game";
import { difficulties } from "@/shared/protocol";
export function Results({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
const [session, setSession] = useState<SessionView | null>(null),
@@ -40,7 +41,9 @@ export function Results({ id }: { id: string }) {
<>
<span className="eyebrow">
{t("EXPEDITION COMPLETE ·")}{" "}
{t(session.difficulty[0].toUpperCase() + session.difficulty.slice(1))}
{session.difficultySettings.id in difficulties
? t(session.difficultySettings.name)
: session.difficultySettings.name}
</span>
<h1>{t("Every guess is a story.")}</h1>
<p className="lead">

View File

@@ -1,11 +1,14 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useRef, useState } from "react";
import { useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
import { difficulties, type Difficulty } from "@/shared/protocol";
import {
difficulties,
type DifficultyPreset,
} from "@/shared/protocol";
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
export function StartGame({
@@ -16,13 +19,65 @@ export function StartGame({
hostName: string | null;
}) {
const { t } = useI18n();
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
const [difficulty, setDifficulty] = useState("normal"),
[customPresets, setCustomPresets] = useState<DifficultyPreset[]>([]),
[showDifficultyForm, setShowDifficultyForm] = useState(false),
[difficultyName, setDifficultyName] = useState(""),
[difficultySeconds, setDifficultySeconds] = useState(90),
[difficultyZoom, setDifficultyZoom] = useState(true),
[savingDifficulty, setSavingDifficulty] = useState(false),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[playerLimit, setPlayerLimit] = useState(4),
[loading, setLoading] = useState(false),
[error, setError] = useState("");
const router = useRouter();
const starting = useRef(false);
useEffect(() => {
let disposed = false;
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
.then(({ presets }) => {
if (!disposed) setCustomPresets(presets);
})
.catch(() => {});
return () => {
disposed = true;
};
}, []);
const options: DifficultyPreset[] = [
...Object.entries(difficulties).map(([id, preset]) => ({
id,
name: preset.label,
seconds: preset.seconds,
zoom: preset.zoom,
})),
...customPresets,
];
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (savingDifficulty) return;
setSavingDifficulty(true);
setError("");
try {
const { preset } = await api<{ preset: DifficultyPreset }>(
"difficulty-presets",
{
name: difficultyName.trim(),
seconds: difficultySeconds,
zoom: difficultyZoom,
},
);
setCustomPresets((current) => [...current, preset]);
setDifficulty(preset.id);
setDifficultyName("");
setDifficultySeconds(90);
setDifficultyZoom(true);
setShowDifficultyForm(false);
} catch (failure) {
setError((failure as Error).message);
} finally {
setSavingDifficulty(false);
}
}
async function start() {
if (starting.current) return;
starting.current = true;
@@ -67,27 +122,97 @@ export function StartGame({
</button>
</div>
<div className="difficulty-options">
{Object.entries(difficulties).map(([key, value]) => (
{options.map((option) => (
<button
key={key}
aria-pressed={difficulty === key}
className={difficulty === key ? "selected" : ""}
key={option.id}
aria-pressed={difficulty === option.id}
className={difficulty === option.id ? "selected" : ""}
disabled={loading}
onClick={() => setDifficulty(key as Difficulty)}
onClick={() => setDifficulty(option.id)}
>
<strong>{t(value.label)}</strong>
<strong>
{option.id in difficulties ? t(option.name) : option.name}
</strong>
<span>
<Timer size={13} />
{value.seconds}
{option.seconds}
{t("s per round")}
</span>
<span>
<ZoomIn size={13} />
{value.zoom ? t("Zoom allowed") : t("No zoom")}
{option.zoom ? t("Zoom allowed") : t("No zoom")}
</span>
</button>
))}
</div>
<div className="difficulty-library">
<button
type="button"
className="text-link"
aria-expanded={showDifficultyForm}
onClick={() => {
setShowDifficultyForm((visible) => !visible);
setError("");
}}
disabled={loading || savingDifficulty}
>
{showDifficultyForm
? t("Cancel")
: "+ " + t("Create custom difficulty")}
</button>
{showDifficultyForm && (
<form className="difficulty-builder" onSubmit={saveDifficulty}>
<label htmlFor="custom-difficulty-name">
{t("Difficulty name")}
</label>
<input
id="custom-difficulty-name"
value={difficultyName}
onChange={(event) => setDifficultyName(event.target.value)}
minLength={2}
maxLength={40}
required
autoComplete="off"
placeholder={t("Name this difficulty")}
disabled={savingDifficulty || loading}
/>
<label htmlFor="custom-difficulty-seconds">
{t("Round time")}
</label>
<div className="difficulty-builder-time">
<input
id="custom-difficulty-seconds"
type="number"
min={15}
max={600}
step={15}
value={difficultySeconds}
onChange={(event) =>
setDifficultySeconds(Number(event.target.value))
}
required
disabled={savingDifficulty || loading}
/>
<span>{t("s per round")}</span>
</div>
<label className="difficulty-builder-zoom">
<input
type="checkbox"
checked={difficultyZoom}
onChange={(event) => setDifficultyZoom(event.target.checked)}
disabled={savingDifficulty || loading}
/>
{t("Allow zoom")}
</label>
<button
className="button primary"
disabled={savingDifficulty || loading}
>
{savingDifficulty ? t("Saving…") : t("Save difficulty")}
</button>
</form>
)}
</div>
<p className="muted">
{t("5 rounds · 25,000 possible points · No movement")}
</p>

View File

@@ -15,6 +15,12 @@ score zero. The owner can advance from the round standings, or the next round
starts automatically after 20 seconds. The final table ranks total points;
equal totals share a place.
Choose one of the built-in difficulties or create a named custom difficulty
with its own round time and zoom setting. Custom difficulties are saved in the
shared MineGeo catalog and can be reused on any map. Multiplayer rooms snapshot
the selected settings when created, so every invited player follows the same
rules.
Players join without an account. A separate HttpOnly cookie identifies each
browser, and the room link acts as the invitation. Only the server can access
the room tables and functions. Clients receive the current round's signed
@@ -43,6 +49,9 @@ Apply `supabase/migrations/20261007071259_multiplayer_rooms.sql` to a new databa
before deploying the web update. It has already been applied to MineGeo's
configured Supabase project. Deployments continue through `npm run deploy`
without a Git-provider connection.
Custom difficulty presets also require
`supabase/migrations/20261013095201_custom_difficulty_presets.sql` before the
web update. No additional environment variables are needed.
Chat additionally requires `supabase/migrations/20261007155622_multiplayer_chat.sql`.
- `npm test`: validation and existing regression checks.

View File

@@ -114,3 +114,13 @@ permissions. Closed reports move to Done and are deleted from MineGeo and Linear
after three days through a durable workflow; no cron job is required.
Glow colors and user prefixes also require `20261008075322_profile_identity.sql`
before deploying the updated account, catalog, and multiplayer pages.
Custom difficulty presets for solo and multiplayer games require
`20261013095201_custom_difficulty_presets.sql` before deploying. The migration
stores the shared presets and snapshots their settings in each game and room.
No additional Vercel environment variables are needed.
Account photo uploads require `20261013123545_profile_avatars.sql` before
deploying. The migration creates the public avatar bucket with a 5 MiB file
limit and owner-scoped upload/delete policies. Existing public Supabase
environment variables are sufficient; no additional secrets are needed.

View File

@@ -21,6 +21,11 @@ import { createHash } from "node:crypto";
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
import { requestOrigin } from "./request-origin";
import { issueInitialPassword } from "@/shared/initial-password";
import {
profileAvatarBucket,
profileAvatarMaxBytes,
profileAvatarMimeTypes,
} from "@/shared/profile-appearance";
type Context = {
jsonBody: (r: NextRequest) => Promise<unknown>;
@@ -102,6 +107,41 @@ export async function accountApi(
{ error: "Earn a username glow before choosing its color" },
403,
);
if (
body.avatarPath !== undefined &&
body.avatarPath !== null &&
body.avatarPath !== current.avatar_path
) {
const [ownerId, filename] = body.avatarPath.split("/");
if (
ownerId !== account.id ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|png|webp)$/i.test(
filename ?? "",
)
)
return json({ error: "Choose a valid profile photo" }, 400);
const uploaded = await admin()
.storage.from(profileAvatarBucket)
.info(body.avatarPath);
if (uploaded.error || !uploaded.data)
return json(
{ error: "Upload your profile photo again" },
400,
);
if (
!profileAvatarMimeTypes.includes(
uploaded.data.contentType as (typeof profileAvatarMimeTypes)[number],
) ||
!uploaded.data.size ||
uploaded.data.size > profileAvatarMaxBytes
) {
await admin().storage.from(profileAvatarBucket).remove([body.avatarPath]);
return json(
{ error: "Choose an image no larger than 5 MB" },
400,
);
}
}
const profile = required(
await admin()
.from("profiles")
@@ -112,13 +152,25 @@ export async function accountApi(
...(body.glowColor !== undefined
? { glow_color: body.glowColor }
: {}),
...(body.avatarPath !== undefined
? { avatar_path: body.avatarPath }
: {}),
})
.eq("id", account.id)
.select(
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
)
.single(),
);
if (
body.avatarPath !== undefined &&
body.avatarPath !== current.avatar_path
) {
if (current.avatar_path?.startsWith(`${account.id}/`))
await admin()
.storage.from(profileAvatarBucket)
.remove([current.avatar_path]);
}
return json({ profile });
}
return json({ error: "Method not allowed" }, 405);

View File

@@ -7,7 +7,7 @@ import { accountControlsApi } from "./account-controls-api";
import {
datasetSchema,
faces,
difficulties,
customDifficultySchema,
parseUploadManifest,
panoramaPath,
viewpointSchema,
@@ -144,6 +144,36 @@ export async function handle(request: NextRequest, path: string[]) {
headers: { "Cache-Control": "no-store" },
});
}
if (path[0] === "difficulty-presets" && path.length === 1) {
if (method === "GET") {
const presets = required(
await client
.from("difficulty_presets")
.select("id,name,seconds,zoom")
.order("name", { ascending: true }),
);
return NextResponse.json({ presets });
}
if (method === "POST") {
const body = customDifficultySchema.parse(await jsonBody(request));
const address =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
await limit("difficulty-presets:" + hash(address), 8, 3600);
const { data, error } = await client
.from("difficulty_presets")
.insert(body)
.select("id,name,seconds,zoom")
.single();
if (error?.code === "23505")
throw new ServiceError(
"A difficulty with that name already exists",
409,
);
checkServiceError(error);
return NextResponse.json({ preset: data });
}
}
if (path[0] === "health" && path.length === 1 && method === "GET") {
const { error } = await client
.from("catalog_servers")
@@ -172,7 +202,10 @@ export async function handle(request: NextRequest, path: string[]) {
const body = z
.object({
datasetId: idSchema,
difficulty: z.enum(["easy", "normal", "hard"]),
difficulty: z.union([
z.enum(["easy", "normal", "hard"]),
idSchema,
]),
})
.parse(await jsonBody(request));
const secret = cookieSecret(request) || randomBytes(32).toString("hex");
@@ -231,6 +264,7 @@ export async function handle(request: NextRequest, path: string[]) {
{
id: s.id,
difficulty: s.difficulty,
difficultySettings: s.difficulty_settings,
round: s.current_round,
bounds: manifest.bounds,
name: manifest.name,

View File

@@ -64,7 +64,7 @@ export async function catalog() {
await client
.from("profiles")
.select(
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
"id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", [
...new Set(
@@ -106,7 +106,7 @@ export async function getMap(id: string) {
? admin()
.from("profiles")
.select(
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
"display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.eq("id", map.owner_id)
.maybeSingle()

View File

@@ -102,11 +102,20 @@ async function announcementSource(
);
hostName = profile.display_name;
}
const difficultySettings =
room.difficulty_settings &&
typeof room.difficulty_settings === "object" &&
!Array.isArray(room.difficulty_settings)
? room.difficulty_settings
: null;
return {
id: room.id,
name: map.data.name,
owner: hostName,
difficulty: room.difficulty,
difficulty:
typeof difficultySettings?.name === "string"
? difficultySettings.name
: room.difficulty,
players: players.length,
limit: room.player_limit,
};

View File

@@ -35,7 +35,7 @@ export async function leaderboard({
await admin()
.from("profiles")
.select(
"id,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
"id,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", ids),
)

View File

@@ -25,7 +25,7 @@ export async function multiplayerAppearance(room: string, ids: string[]) {
await db
.from("profiles")
.select(
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
"id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", accounts),
);

View File

@@ -21,7 +21,7 @@ export async function accountProfile(account: User) {
await admin()
.from("profiles")
.select(
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
)
.eq("id", account.id)
.single(),

View File

@@ -2,6 +2,12 @@ import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
const config: NextConfig = {
poweredByHeader: false,
cacheMaxMemorySize: 16 * 1024 * 1024,
enablePrerenderSourceMaps: false,
experimental: {
cpus: 1,
preloadEntriesOnStart: false,
},
outputFileTracingIncludes: { "/downloads": ["./public/tools/**/*.jar"] },
serverExternalPackages: ["pg"],
turbopack: { root: process.cwd() },

33
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"server-only": "0.0.1",
"sharp": "0.35.5",
"three": "0.186.1",
"tsx": "4.20.6",
"tus-js-client": "4.3.1",
"workflow": "5.2.0",
"zod": "4.1.12"
@@ -37,7 +38,6 @@
"agent-browser": "0.21.0",
"prettier": "3.6.2",
"tailwindcss": "4.1.14",
"tsx": "4.20.6",
"typescript": "5.9.3"
},
"engines": {
@@ -280,7 +280,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -297,7 +296,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -314,7 +312,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -331,7 +328,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -348,7 +344,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -365,7 +360,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -382,7 +376,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -399,7 +392,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -416,7 +408,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -433,7 +424,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -450,7 +440,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -467,7 +456,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -484,7 +472,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -501,7 +488,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -518,7 +504,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -535,7 +520,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -552,7 +536,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -569,7 +552,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -586,7 +568,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -603,7 +584,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -620,7 +600,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -637,7 +616,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -654,7 +632,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -671,7 +648,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -688,7 +664,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -705,7 +680,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6471,7 +6445,6 @@
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -6776,7 +6749,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -6855,7 +6827,6 @@
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz",
"integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -8558,7 +8529,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -9305,7 +9275,6 @@
"version": "4.20.6",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",

View File

@@ -42,6 +42,7 @@
"server-only": "0.0.1",
"sharp": "0.35.5",
"three": "0.186.1",
"tsx": "4.20.6",
"tus-js-client": "4.3.1",
"workflow": "5.2.0",
"zod": "4.1.12"
@@ -58,7 +59,6 @@
"agent-browser": "0.21.0",
"prettier": "3.6.2",
"tailwindcss": "4.1.14",
"tsx": "4.20.6",
"typescript": "5.9.3"
},
"engines": {

View File

@@ -234,7 +234,11 @@ export async function deploy(
const files = [];
for (const file of payload.files) {
const data = Buffer.from(file.data || "", "base64");
if (file.encoding !== "base64" || data.length <= 10 * 1024 * 1024) {
const isDesktopReleasePart = file.file.startsWith(".desktop-release/part-");
if (
file.encoding !== "base64" ||
(data.length <= 10 * 1024 * 1024 && !isDesktopReleasePart)
) {
files.push(file);
continue;
}

View File

@@ -42,10 +42,14 @@ export const profileUpdateSchema = z
.object({
displayName: displayNameSchema.optional(),
glowColor: glowColorSchema.optional(),
avatarPath: z.string().max(128).nullable().optional(),
})
.strict()
.refine(
(value) => value.displayName !== undefined || value.glowColor !== undefined,
(value) =>
value.displayName !== undefined ||
value.glowColor !== undefined ||
value.avatarPath !== undefined,
"Choose a profile change",
);
export const initialPasswordSchema = z

View File

@@ -151,6 +151,7 @@ export type Database = {
revision_id: string;
host_player_id: string;
difficulty: string;
difficulty_settings: Json;
player_limit: number;
status: string;
current_round: number;
@@ -162,6 +163,7 @@ export type Database = {
revision_id: string;
host_player_id: string;
difficulty: string;
difficulty_settings: Json;
player_limit: number;
status?: string;
current_round?: number;
@@ -176,6 +178,28 @@ export type Database = {
};
Relationships: [];
};
difficulty_presets: {
Row: {
created_at: string;
id: string;
name: string;
seconds: number;
zoom: boolean;
};
Insert: {
created_at?: string;
id?: string;
name: string;
seconds: number;
zoom: boolean;
};
Update: {
name?: string;
seconds?: number;
zoom?: boolean;
};
Relationships: [];
};
multiplayer_rounds: {
Row: {
room_id: string;
@@ -476,6 +500,7 @@ export type Database = {
created_at: string;
current_round: number;
difficulty: string;
difficulty_settings: Json;
id: string;
owner_id: string | null;
revision_id: string;
@@ -485,6 +510,7 @@ export type Database = {
created_at?: string;
current_round?: number;
difficulty: string;
difficulty_settings: Json;
id?: string;
owner_id?: string | null;
revision_id: string;
@@ -494,6 +520,7 @@ export type Database = {
created_at?: string;
current_round?: number;
difficulty?: string;
difficulty_settings?: Json;
id?: string;
owner_id?: string | null;
revision_id?: string;
@@ -540,6 +567,7 @@ export type Database = {
};
profiles: {
Row: AccountStatusFields & {
avatar_path: string | null;
glow_color: string;
bug_finder: boolean;
custom_prefix: string | null;
@@ -551,6 +579,7 @@ export type Database = {
role: string;
};
Insert: Partial<AccountStatusFields> & {
avatar_path?: string | null;
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
@@ -562,6 +591,7 @@ export type Database = {
role?: string;
};
Update: Partial<AccountStatusFields> & {
avatar_path?: string | null;
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;

View File

@@ -169,6 +169,16 @@
"← My account": "← Мой аккаунт",
"Your public profile.": "Твой публичный профиль.",
"A name for the maps and places you share.": "Имя для карт и мест, которыми ты делишься.",
"Profile photo": "Фото профиля",
"JPG, PNG or WebP · Maximum size: 5 MB.": "JPG, PNG или WebP · Максимальный размер: 5 МБ.",
"Images must be 5 MB or smaller.": "Изображение должно быть не больше 5 МБ.",
"Choose a JPG, PNG or WebP image.": "Выбери изображение JPG, PNG или WebP.",
"Could not upload your profile photo. Please try again.": "Не удалось загрузить фото профиля. Попробуй ещё раз.",
"Upload your profile photo again": "Загрузи фото профиля ещё раз",
"Choose a valid profile photo": "Выбери подходящее фото профиля",
"Choose an image no larger than 5 MB": "Выбери изображение размером не больше 5 МБ",
"Remove photo": "Удалить фото",
"Profile photo saved.": "Фото профиля сохранено.",
"2–32 characters. Display names do not have to be unique.": "От 2 до 32 символов. Имя не обязано быть уникальным.",
"Used for sign-in and recovery. Only you can see it.": "Для входа и восстановления. Почту видишь только ты.",
"Save display name": "Сохранить имя",
@@ -230,6 +240,13 @@
"Terrain of {name}": "Рельеф карты {name}",
"YOUR EXPEDITION": "ТВОЯ ЭКСПЕДИЦИЯ",
"Choose your challenge.": "Выбери сложность.",
"Difficulty": "Сложность",
"Create custom difficulty": "Создать свою сложность",
"Difficulty name": "Название сложности",
"Name this difficulty": "Например, «Хардкор»",
"Round time": "Время на раунд",
"Allow zoom": "Разрешить приближение",
"Save difficulty": "Сохранить сложность",
"s per round": "с на раунд",
"Zoom allowed": "Можно приближать",
"No zoom": "Без приближения",

View File

@@ -1,11 +1,14 @@
import { z } from "zod";
import { displayNameSchema } from "./account";
import type { Bounds, Difficulty } from "./protocol";
import type { Bounds, DifficultyPreset } from "./protocol";
export const createRoomSchema = z
.object({
datasetId: z.string().uuid(),
difficulty: z.enum(["easy", "normal", "hard"]),
difficulty: z.union([
z.enum(["easy", "normal", "hard"]),
z.string().uuid(),
]),
playerLimit: z.number().int().min(2).max(16),
})
.strict();
@@ -53,7 +56,8 @@ export type RoomView = {
id: string;
datasetId: string;
name: string;
difficulty: Difficulty;
difficulty: string;
difficultySettings: DifficultyPreset;
playerLimit: number;
playerCount: number;
status: "lobby" | "loading" | "playing" | "results" | "finished" | "closed";

View File

@@ -1,5 +1,12 @@
import { z } from "zod";
export const defaultGlowColor = "#ffe6a1";
export const profileAvatarBucket = "minegeo-avatars";
export const profileAvatarMaxBytes = 5 * 1024 * 1024;
export const profileAvatarMimeTypes = [
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const glowColorSchema = z
.string()
.regex(/^#[a-fA-F0-9]{6}$/, "Choose a valid glow color")
@@ -28,6 +35,7 @@ export const prefixUpdateSchema = z
.object({ prefix: customPrefixSchema.nullable(), icon: z.enum(prefixIcons) })
.strict();
export type ProfileAppearance = {
avatar_path?: string | null;
name_glow?: boolean;
glow_color?: string;
role?: string;
@@ -36,7 +44,7 @@ export type ProfileAppearance = {
custom_prefix_icon?: string;
};
export const appearanceColumns =
"name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
"avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
export type BadgeUser = ProfileAppearance & {
id: string;
display_name: string;
@@ -44,3 +52,10 @@ export type BadgeUser = ProfileAppearance & {
export function glowColor(value?: string) {
return glowColorSchema.safeParse(value).data ?? defaultGlowColor;
}
export function profileAvatarUrl(path?: string | null) {
const base = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, "");
if (!base || !path) return null;
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
return `${base}/storage/v1/object/public/${profileAvatarBucket}/${encodedPath}`;
}

View File

@@ -171,6 +171,35 @@ export const difficulties = {
hard: { seconds: 30, zoom: false, label: "Hard" },
} as const;
export type Difficulty = keyof typeof difficulties;
export type DifficultyPreset = {
id: string;
name: string;
seconds: number;
zoom: boolean;
};
export const customDifficultySchema = z
.object({
name: z
.string()
.trim()
.min(2)
.max(40)
.refine(
(name) =>
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
name,
),
"Difficulty names cannot contain hidden controls",
),
seconds: z
.number()
.int()
.min(15)
.max(600)
.refine((seconds) => seconds % 15 === 0),
zoom: z.boolean(),
})
.strict();
export function scoreGuess(
a: Pick<Point, "x" | "z">,
b: Pick<Point, "x" | "z">,

View File

@@ -0,0 +1,361 @@
-- Store shared custom difficulty presets and snapshot their rules in each game.
-- Writes stay behind the MineGeo server API; the exposed public schema grants
-- no table access to anon or authenticated clients.
create table public.difficulty_presets (
id uuid primary key default gen_random_uuid(),
name text not null check (
name = btrim(name)
and length(name) between 2 and 40
and name !~ '[[:cntrl:]]'
and name !~ U&'[\\202A-\\202E\\2066-\\2069]'
),
seconds integer not null check (
seconds between 15 and 600 and seconds % 15 = 0
),
zoom boolean not null,
created_at timestamptz not null default now()
);
create unique index difficulty_presets_name
on public.difficulty_presets (lower(name));
alter table public.difficulty_presets enable row level security;
revoke all on public.difficulty_presets from public, anon, authenticated;
grant select, insert on public.difficulty_presets to service_role;
alter table public.game_sessions
drop constraint if exists game_sessions_difficulty_check;
alter table public.game_sessions
add column difficulty_settings jsonb;
update public.game_sessions
set difficulty_settings = jsonb_build_object(
'id', difficulty,
'name', initcap(difficulty),
'seconds', case difficulty when 'easy' then 180 when 'hard' then 30 else 90 end,
'zoom', difficulty <> 'hard'
);
alter table public.game_sessions
alter column difficulty_settings set not null;
alter table public.game_sessions
add constraint game_sessions_difficulty_check check (
difficulty in ('easy', 'normal', 'hard')
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
);
alter table public.multiplayer_rooms
drop constraint if exists multiplayer_rooms_difficulty_check;
alter table public.multiplayer_rooms
add column difficulty_settings jsonb;
update public.multiplayer_rooms
set difficulty_settings = jsonb_build_object(
'id', difficulty,
'name', initcap(difficulty),
'seconds', case difficulty when 'easy' then 180 when 'hard' then 30 else 90 end,
'zoom', difficulty <> 'hard'
);
alter table public.multiplayer_rooms
alter column difficulty_settings set not null;
alter table public.multiplayer_rooms
add constraint multiplayer_rooms_difficulty_check check (
difficulty in ('easy', 'normal', 'hard')
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
);
create or replace function public.resolve_difficulty_preset(p_id text)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare preset jsonb;
begin
case p_id
when 'easy' then
return jsonb_build_object('id', 'easy', 'name', 'Easy', 'seconds', 180, 'zoom', true);
when 'normal' then
return jsonb_build_object('id', 'normal', 'name', 'Normal', 'seconds', 90, 'zoom', true);
when 'hard' then
return jsonb_build_object('id', 'hard', 'name', 'Hard', 'seconds', 30, 'zoom', false);
end case;
select jsonb_build_object(
'id', p.id::text,
'name', p.name,
'seconds', p.seconds,
'zoom', p.zoom
)
into preset
from public.difficulty_presets p
where p.id::text = lower(p_id);
if not found then raise exception 'Choose a saved difficulty'; end if;
return preset;
end;
$$;
revoke all on function public.resolve_difficulty_preset(text)
from public, anon, authenticated;
grant execute on function public.resolve_difficulty_preset(text) to service_role;
create or replace function public.create_game(
p_dataset uuid,
p_difficulty text,
p_owner uuid,
p_access_hash text
) returns uuid language plpgsql security invoker set search_path = '' as $$
declare
sid uuid;
revision uuid;
rev public.revisions;
vp jsonb;
n integer := 0;
settings jsonb;
begin
settings := public.resolve_difficulty_preset(p_difficulty);
revision := public.playable_map_revision(p_dataset, p_owner);
select * into rev from public.revisions where id = revision;
if not found then raise exception 'Map unavailable'; end if;
if jsonb_array_length(rev.manifest->'viewpoints') < 5 then
raise exception 'Map has fewer than five viewpoints';
end if;
insert into public.game_sessions(
revision_id, owner_id, access_hash, difficulty, difficulty_settings
) values (rev.id, p_owner, p_access_hash, p_difficulty, settings)
returning id into sid;
for vp in
select value from jsonb_array_elements(rev.manifest->'viewpoints')
order by random() limit 5
loop
insert into public.game_rounds(session_id, round_number, viewpoint)
values (sid, n, vp);
n := n + 1;
end loop;
return sid;
end;
$$;
create or replace function public.activate_round(
p_session uuid,
p_round integer,
p_access_hash text
) returns timestamptz language plpgsql security invoker set search_path = '' as $$
declare
s public.game_sessions;
r public.game_rounds;
seconds integer;
begin
select * into s from public.game_sessions
where id = p_session and access_hash = p_access_hash for update;
if not found then raise exception 'Session not found'; end if;
if s.current_round <> p_round then raise exception 'Round is not current'; end if;
select * into r from public.game_rounds
where session_id = p_session and round_number = p_round for update;
if r.result is not null then raise exception 'Round already finished'; end if;
if r.deadline is not null then return r.deadline; end if;
seconds := (s.difficulty_settings->>'seconds')::integer;
update public.game_rounds
set deadline = clock_timestamp() + make_interval(secs => seconds)
where session_id = p_session and round_number = p_round
returning deadline into r.deadline;
return r.deadline;
end;
$$;
create or replace function public.create_map_room(
p_dataset uuid,
p_difficulty text,
p_name text,
p_limit integer,
p_access_hash text,
p_owner uuid
) returns uuid language plpgsql security invoker set search_path = '' as $$
declare
rid uuid;
revision uuid;
pid uuid := gen_random_uuid();
rev public.revisions;
vp jsonb;
n integer := 0;
settings jsonb;
begin
if p_limit is null or p_limit not between 2 and 16 then
raise exception 'Choose between 2 and 16 players';
end if;
settings := public.resolve_difficulty_preset(p_difficulty);
revision := public.playable_map_revision(p_dataset, p_owner);
select * into rev from public.revisions where id = revision;
if not found then raise exception 'Map unavailable'; end if;
if coalesce(jsonb_array_length(rev.manifest->'viewpoints'), 0) < 5 then
raise exception 'Map has fewer than five viewpoints';
end if;
insert into public.multiplayer_rooms(
revision_id, host_player_id, difficulty, difficulty_settings, player_limit
) values (rev.id, pid, p_difficulty, settings, p_limit)
returning id into rid;
insert into public.multiplayer_players(
id, room_id, access_hash, display_name
) values (pid, rid, p_access_hash, btrim(p_name));
for vp in
select value from jsonb_array_elements(rev.manifest->'viewpoints')
order by random() limit 5
loop
insert into public.multiplayer_rounds(room_id, round_number, viewpoint)
values (rid, n, vp);
n := n + 1;
end loop;
return rid;
end;
$$;
create or replace function public.create_multiplayer_room(
p_dataset uuid,
p_difficulty text,
p_name text,
p_limit integer,
p_access_hash text
) returns uuid language sql security invoker set search_path = '' as $$
select public.create_map_room(
p_dataset, p_difficulty, p_name, p_limit, p_access_hash, null
);
$$;
create or replace function public.multiplayer_tick(p_room uuid)
returns void language plpgsql security invoker set search_path = '' as $$
declare
room public.multiplayer_rooms;
turn public.multiplayer_rounds;
seconds integer;
begin
select * into room from public.multiplayer_rooms where id = p_room for update;
if room.status not in ('finished', 'closed')
and room.created_at < clock_timestamp() - interval '24 hours' then
update public.multiplayer_rooms
set status = 'closed', transition_at = null where id = p_room;
return;
end if;
if room.status = 'results' and room.transition_at <= clock_timestamp() then
update public.multiplayer_rooms
set current_round = current_round + 1,
status = 'loading',
transition_at = clock_timestamp() + interval '30 seconds'
where id = p_room returning * into room;
end if;
if room.status = 'loading' and (
room.transition_at <= clock_timestamp()
or not exists (
select 1 from public.multiplayer_players
where room_id = p_room and not has_left
and ready_round <> room.current_round
)
) then
seconds := (room.difficulty_settings->>'seconds')::integer;
update public.multiplayer_rounds
set deadline = clock_timestamp() + make_interval(secs => seconds)
where room_id = p_room and round_number = room.current_round;
update public.multiplayer_rooms
set status = 'playing', transition_at = null
where id = p_room returning * into room;
end if;
if room.status <> 'playing' then return; end if;
select * into turn from public.multiplayer_rounds
where room_id = p_room and round_number = room.current_round;
insert into public.multiplayer_guesses(room_id, player_id, round_number, result)
select p_room, p.id, room.current_round,
jsonb_build_object(
'score', 0, 'distance', null, 'timedOut', true, 'guess', null,
'actual', jsonb_build_object(
'x', turn.viewpoint->'x', 'z', turn.viewpoint->'z'
)
)
from public.multiplayer_players p
where p.room_id = p_room
and (p.has_left or turn.deadline <= clock_timestamp())
on conflict (room_id, player_id, round_number) do nothing;
if not exists (
select 1 from public.multiplayer_players p
where p.room_id = p_room and not exists (
select 1 from public.multiplayer_guesses g
where g.room_id = p_room and g.player_id = p.id
and g.round_number = room.current_round
)
) then
update public.multiplayer_rounds set closed_at = clock_timestamp()
where room_id = p_room and round_number = room.current_round;
update public.multiplayer_rooms
set status = case when current_round = 4 then 'finished' else 'results' end,
transition_at = case when current_round = 4 then null
else clock_timestamp() + interval '20 seconds' end
where id = p_room;
end if;
end;
$$;
create or replace function public.multiplayer_snapshot(
p_room uuid,
p_access_hash text
) returns jsonb language sql security invoker set search_path = '' as $$
with room as (select * from public.multiplayer_rooms where id = p_room),
viewer as (
select p.id from public.multiplayer_players p
where p.room_id = p_room and p.access_hash = p_access_hash and not p.has_left
),
totals as (
select p.*,
coalesce((
select sum((g.result->>'score')::integer)
from public.multiplayer_guesses g
join public.multiplayer_rounds r
on r.room_id = g.room_id and r.round_number = g.round_number
where g.room_id = p_room and g.player_id = p.id and r.closed_at is not null
), 0) as total
from public.multiplayer_players p where p.room_id = p_room
), ranked as (
select *, dense_rank() over (order by total desc) as rank from totals
)
select jsonb_build_object(
'id', room.id,
'datasetId', rev.dataset_id,
'name', rev.manifest->>'name',
'difficulty', room.difficulty,
'difficultySettings', room.difficulty_settings,
'playerLimit', room.player_limit,
'playerCount', (select count(*) from totals where not has_left),
'status', room.status,
'round', room.current_round,
'hostId', room.host_player_id,
'you', (select id from viewer),
'serverNow', floor(extract(epoch from clock_timestamp()) * 1000),
'transitionAt', room.transition_at,
'players', case when exists(select 1 from viewer) then coalesce((
select jsonb_agg(jsonb_build_object(
'id', p.id,
'name', p.display_name,
'left', p.has_left,
'ready', p.ready_round = room.current_round,
'submitted', exists(
select 1 from public.multiplayer_guesses g
where g.room_id = p_room and g.player_id = p.id
and g.round_number = room.current_round
),
'total', p.total,
'rank', p.rank
) order by p.rank, p.joined_at, p.id)
from ranked p
), '[]'::jsonb) else '[]'::jsonb end,
'rounds', case when exists(select 1 from viewer) then coalesce((
select jsonb_agg(jsonb_build_object(
'number', r.round_number,
'deadline', r.deadline,
'closedAt', r.closed_at,
'results', case when r.closed_at is not null then coalesce((
select jsonb_agg(
g.result || jsonb_build_object('playerId', g.player_id)
order by g.player_id
)
from public.multiplayer_guesses g
where g.room_id = p_room and g.round_number = r.round_number
), '[]'::jsonb) else '[]'::jsonb end
) order by r.round_number)
from public.multiplayer_rounds r where r.room_id = p_room
), '[]'::jsonb) else '[]'::jsonb end,
'myGuess', case when exists(select 1 from viewer) then (
select g.result->'guess' from public.multiplayer_guesses g
where g.room_id = p_room and g.player_id = (select id from viewer)
and g.round_number = room.current_round
) else null end
)
from room join public.revisions rev on rev.id = room.revision_id;
$$;

View File

@@ -0,0 +1,34 @@
alter table public.profiles
add column avatar_path text;
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
values (
'minegeo-avatars',
'minegeo-avatars',
true,
5242880,
array['image/jpeg', 'image/png', 'image/webp']
)
on conflict (id) do update set
name = excluded.name,
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy "MineGeo users upload their own avatars"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'minegeo-avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
create policy "MineGeo users delete their own avatars"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'minegeo-avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);